From 3d4b6f3a6562743bd627602ca85cb9d7c8314d7d Mon Sep 17 00:00:00 2001 From: Keith Brings Date: Tue, 12 Mar 2024 00:46:48 +0700 Subject: [PATCH] Extending phx.gen.* functions to suport custom generation type handlers. Custom generators provide an appropriate migration field type, schema field type and live view form input and input hydration hooks.This allows projects to build out custom/house type live view form input handlers and use of engine specific type mapping such as postgres user types, jsonb, etc. with out the need to manually edit generated live views. --- .gitignore | 10 ++ lib/mix/phoenix/custom_generator_behavior.ex | 82 +++++++++ lib/mix/phoenix/schema.ex | 29 +++- lib/mix/tasks/phx.gen.live.ex | 5 +- priv/templates/phx.gen.live/form_component.ex | 24 +++ test/mix/tasks/phx.gen.live_test.exs | 164 ++++++++++++++++++ test/mix/tasks/phx.gen.schema_test.exs | 72 ++++++++ 7 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 lib/mix/phoenix/custom_generator_behavior.ex diff --git a/.gitignore b/.gitignore index 44fba26034..943c1c1b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +# Intellij +.idea/ +installer/phx_new.iml +installer/templates/phx_single/phx_single.iml +installer/templates/phx_umbrella/apps/app_name/app_name.iml +installer/templates/phx_umbrella/apps/app_name_web/app_name_web.iml +installer/templates/phx_umbrella/phx_umbrella.iml +integration_test/phoenix_integration.iml +phoenix.iml + /_build/ /deps/ /doc/ diff --git a/lib/mix/phoenix/custom_generator_behavior.ex b/lib/mix/phoenix/custom_generator_behavior.ex new file mode 100644 index 0000000000..39024583ef --- /dev/null +++ b/lib/mix/phoenix/custom_generator_behavior.ex @@ -0,0 +1,82 @@ +defmodule Mix.Phoenix.CustomGeneratorBehaviour do + @moduledoc """ + This module defines the behavior for custom generators. + Implement modules that implement this behavior and use with mix phx.gen.live,phx.gen.schema,phx.gen.context to + extend default generators with project specific types and form components. + + For example if using a postgres user type enums you can use type_for_migration/1 to return the user type as an atom and + type_and_opts_for_schema/1 to return an Ecto.Enum, values: [] string + by implementing a custom generator along the lines of MyProject.CustomEnumGenerator passed to mix phx.gen.schema as + "field:MyProject.CustomEnumGenerator:custom_user_type:list:of:allowed:values" + """ + + @doc """ + Unpack custom generator and it's options. + Return {key, {:custom, __MODULE__, unpacked_options | nil}} + """ + @callback validate_attr!(attrs :: tuple) :: {name :: atom, {:custom, provider :: atom, opts :: any}} | {term, term} + + @doc """ + return the string that will be used to populate schema field e.g. a string like "Ecto.Enum, values: [:a,:b,:c]" + """ + @callback type_and_opts_for_schema(attrs :: any) :: String.t() + + @doc """ + return the ecto migration field type term. e.g. {:enum, [:a,:b,:c]} + """ + @callback type_for_migration(opts :: any) :: term + + @doc """ + return the default value for the field type used by live view and ecto tests. + """ + @callback type_to_default(key :: atom, opts :: any, action :: atom) :: any + + @doc """ + return the input/live component used to display this custom field type in a live view form. + """ + @callback live_form_input(key :: atom, opts :: any) :: String.t() | nil + + @doc """ + used for unpacking a complex type that requires serialization from one or more form params. + For example if your live_form_input routes to a live component that uses a hidden input field containing json that needs + to be unpacked before passing to the module's changeset. + return params if no special processing required. + """ + @callback hydrate_form_input(key :: atom, params :: Map.t, opts :: any) :: Map.t + + + @doc """ + Pass to behavior provider. @see validate_attr!/1 + """ + def validate_attr!(provider, attrs) do + apply(provider, :validate_attr!, [attrs]) + end + + @doc """ + Pass to behavior provider. @see type_and_opts_for_schema/1 + """ + def type_and_opts_for_schema(provider, opts) do + apply(provider, :type_and_opts_for_schema, [opts]) + end + + @doc """ + Pass to behavior provider. @see type_for_migration/1 + """ + def type_for_migration(provider, opts) do + apply(provider, :type_for_migration, [opts]) + end + + @doc """ + Pass to behavior provider. @see `type_to_default/3` + """ + def type_to_default(provider, key, opts, action) do + apply(provider, :type_to_default, [key, opts, action]) + end + + @doc """ + Pass to behavior provider. @see live_form_input/2 + """ + def live_form_input(provider, key, opts) do + apply(provider, :live_form_input, [key, opts]) + end +end diff --git a/lib/mix/phoenix/schema.ex b/lib/mix/phoenix/schema.ex index f77af333c2..528863e9a3 100644 --- a/lib/mix/phoenix/schema.ex +++ b/lib/mix/phoenix/schema.ex @@ -247,6 +247,7 @@ defmodule Mix.Phoenix.Schema do end def type_for_migration({:enum, _}), do: :string + def type_for_migration({:custom, provider, opts}), do: Mix.Phoenix.CustomGeneratorBehaviour.type_for_migration(provider, opts) def type_for_migration(other), do: other def format_fields_for_schema(schema) do @@ -265,7 +266,7 @@ defmodule Mix.Phoenix.Schema do def type_and_opts_for_schema({:enum, opts}), do: ~s|Ecto.Enum, values: #{inspect(Keyword.get(opts, :values))}| - + def type_and_opts_for_schema({:custom, provider, opts}), do: Mix.Phoenix.CustomGeneratorBehaviour.type_and_opts_for_schema(provider, opts) def type_and_opts_for_schema(other), do: inspect(other) def maybe_redact_field(true), do: ", redact: true" @@ -352,6 +353,9 @@ defmodule Mix.Phoenix.Schema do :naive_datetime_usec -> NaiveDateTime.add(build_utc_naive_datetime_usec(), -@one_day_in_seconds) + {:custom, provider, opts} -> + Mix.Phoenix.CustomGeneratorBehaviour.type_to_default(provider, key, opts, :create) + _ -> "some #{key}" end @@ -375,6 +379,8 @@ defmodule Mix.Phoenix.Schema do :utc_datetime_usec -> build_utc_datetime_usec() :naive_datetime -> build_utc_naive_datetime() :naive_datetime_usec -> build_utc_naive_datetime_usec() + {:custom, provider, opts} -> + Mix.Phoenix.CustomGeneratorBehaviour.type_to_default(provider, key, opts, :update) _ -> "some updated #{key}" end end @@ -436,12 +442,21 @@ defmodule Mix.Phoenix.Schema do defp validate_attr!({_name, type} = attr) when type in @valid_types, do: attr defp validate_attr!({_name, {:enum, _vals}} = attr), do: attr defp validate_attr!({_name, {type, _}} = attr) when type in @valid_types, do: attr - - defp validate_attr!({_, type}) do - Mix.raise( - "Unknown type `#{inspect(type)}` given to generator. " <> - "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}" - ) + defp validate_attr!({name, {type, opts}}) do + if Kernel.function_exported?(:"Elixir.#{type}", :validate_attr!, 1) do + Mix.Phoenix.CustomGeneratorBehaviour.validate_attr!(:"Elixir.#{type}", {name, :"Elixir.#{type}", opts}) + else + Mix.raise("Unknown type `#{inspect(type)}` given to generator. " <> + "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}") + end + end + defp validate_attr!({name, type}) do + if Kernel.function_exported?(:"Elixir.#{type}", :validate_attr!, 1) do + Mix.Phoenix.CustomGeneratorBehaviour.validate_attr!(:"Elixir.#{type}", {name, :"Elixir.#{type}", []}) + else + Mix.raise("Unknown type `#{inspect(type)}` given to generator. " <> + "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}") + end end defp partition_attrs_and_assocs(schema_module, attrs) do diff --git a/lib/mix/tasks/phx.gen.live.ex b/lib/mix/tasks/phx.gen.live.ex index 825b27190a..57e65059aa 100644 --- a/lib/mix/tasks/phx.gen.live.ex +++ b/lib/mix/tasks/phx.gen.live.ex @@ -326,9 +326,12 @@ defmodule Mix.Tasks.Phx.Gen.Live do /> """ + {key, {:custom, provider, opts}} -> + Mix.Phoenix.CustomGeneratorBehaviour.live_form_input(provider, key, opts) + {key, _} -> ~s(<.input field={@form[#{inspect(key)}]} type="text" label="#{label(key)}" />) - end) + end) |> Enum.reject(&is_nil/1) end defp default_options({:array, :string}), diff --git a/priv/templates/phx.gen.live/form_component.ex b/priv/templates/phx.gen.live/form_component.ex index 52cf6e85b4..65fb8509a1 100644 --- a/priv/templates/phx.gen.live/form_component.ex +++ b/priv/templates/phx.gen.live/form_component.ex @@ -40,6 +40,18 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web @impl true def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do + <%= + Enum.filter(schema.types, + fn + {_, {:custom, _, _}} -> true + _ -> false + end) + |> Enum.map_join("\n ", + fn + {key, {:custom, provider, opts}} -> + "#{ schema.singular }_params = #{inspect provider}.hydrate_form_input(#{inspect key}, #{ schema.singular }_params, #{inspect opts, limit: :infinity})" + end) + %> changeset = socket.assigns.<%= schema.singular %> |> <%= inspect context.alias %>.change_<%= schema.singular %>(<%= schema.singular %>_params) @@ -49,6 +61,18 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end def handle_event("save", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do + <%= + Enum.filter(schema.types, + fn + {_, {:custom, _, _}} -> true + _ -> false + end) + |> Enum.map_join("\n ", + fn + {key, {:custom, provider, opts}} -> + "#{ schema.singular }_params = #{inspect provider}.hydrate_form_input(#{inspect key}, #{ schema.singular }_params, #{inspect opts, limit: :infinity})" + end) + %> save_<%= schema.singular %>(socket, socket.assigns.action, <%= schema.singular %>_params) end diff --git a/test/mix/tasks/phx.gen.live_test.exs b/test/mix/tasks/phx.gen.live_test.exs index 20108f396d..dc5e046d73 100644 --- a/test/mix/tasks/phx.gen.live_test.exs +++ b/test/mix/tasks/phx.gen.live_test.exs @@ -1,10 +1,38 @@ Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ + +defmodule Phoenix.Test.LiveCustomGen do + @behaviour Mix.Phoenix.CustomGeneratorBehaviour + def validate_attr!({name, __MODULE__, opts}) do + {name, {:custom, __MODULE__, opts}} + end + def type_and_opts_for_schema(_opts) do + ~s|:custom_schema_type| + end + def type_for_migration(_opts) do + :custom_ecto_type + end + def type_to_default(key, _opts, :create) do + "Special #{key}" + end + def type_to_default(key, _opts, :update) do + "Special Updated #{key}" + end + def live_form_input(key, _opts) do + "[SPECIAL INPUT HANDLER: #{key}]" + end + def hydrate_form_input(_key, params, _opts), do: params +end + + defmodule Mix.Tasks.Phx.Gen.LiveTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen + @moduletag feature: :gen + @moduletag gen: :live + setup do Mix.Task.clear() :ok @@ -29,6 +57,142 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do end) end + + describe "custom schema generator support" do + test "generate", config do + in_tmp_live_project config.test, fn -> + Gen.Live.run(~w(Blog Post posts title:Phoenix.Test.LiveCustomGen slug:unique votes:integer cost:decimal + tags:array:text popular:boolean drafted_at:datetime + status:enum:unpublished:published:deleted + published_at:utc_datetime + published_at_usec:utc_datetime_usec + deleted_at:naive_datetime + deleted_at_usec:naive_datetime_usec + alarm:time + alarm_usec:time_usec + secret:uuid:redact announcement_date:date alarm:time + metadata:map + weight:float user_id:references:users)) + + assert_file "lib/phoenix/blog/post.ex" + assert_file "lib/phoenix/blog.ex" + assert_file "test/phoenix/blog_test.exs" + + assert_file "lib/phoenix_web/live/post_live/index.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.Index" + end + + assert_file "lib/phoenix_web/live/post_live/show.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.Show" + end + + assert_file "lib/phoenix_web/live/post_live/form_component.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.FormComponent" + end + + assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") + assert_file path, fn file -> + assert file =~ "create table(:posts)" + assert file =~ "add :title, :custom_ecto_type" + assert file =~ "create unique_index(:posts, [:slug])" + end + + assert_file "lib/phoenix_web/live/post_live/index.html.heex", fn file -> + assert file =~ ~S|~p"/posts"| + end + + assert_file "lib/phoenix_web/live/post_live/show.html.heex", fn file -> + assert file =~ ~S|~p"/posts"| + end + + assert_file "lib/phoenix_web/live/post_live/form_component.ex", fn file -> + assert file =~ ~s(<.simple_form) + assert file =~ ~s([SPECIAL INPUT HANDLER: title]) + assert file =~ ~s(<.input field={@form[:votes]} type="number") + assert file =~ ~s(<.input field={@form[:cost]} type="number" label="Cost" step="any") + assert file =~ """ + <.input + field={@form[:tags]} + type="select" + multiple + """ + assert file =~ ~s(<.input field={@form[:popular]} type="checkbox") + assert file =~ ~s(<.input field={@form[:drafted_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:published_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:deleted_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:announcement_date]} type="date") + assert file =~ ~s(<.input field={@form[:alarm]} type="time") + assert file =~ ~s(<.input field={@form[:secret]} type="text" label="Secret" />) + refute file =~ ~s( + assert file =~ ~r"@invalid_attrs.*popular: false" + assert file =~ ~S|~p"/posts"| + assert file =~ ~S|~p"/posts/new"| + assert file =~ ~S|~p"/posts/#{post}"| + assert file =~ ~S|~p"/posts/#{post}/show/edit"| + end + + send self(), {:mix_shell_input, :yes?, true} + Gen.Live.run(~w(Blog Comment comments title:string)) + assert_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} + + assert_file "lib/phoenix/blog/comment.ex" + assert_file "test/phoenix_web/live/comment_live_test.exs", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLiveTest" + end + + assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") + assert_file path, fn file -> + assert file =~ "create table(:comments)" + assert file =~ "add :title, :string" + end + + assert_file "lib/phoenix_web/live/comment_live/index.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.Index" + end + + assert_file "lib/phoenix_web/live/comment_live/show.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.Show" + end + + assert_file "lib/phoenix_web/live/comment_live/form_component.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.FormComponent" + end + + assert_receive {:mix_shell, :info, [""" + + Add the live routes to your browser scope in lib/phoenix_web/router.ex: + + live "/comments", CommentLive.Index, :index + live "/comments/new", CommentLive.Index, :new + live "/comments/:id/edit", CommentLive.Index, :edit + + live "/comments/:id", CommentLive.Show, :show + live "/comments/:id/show/edit", CommentLive.Show, :edit + """]} + + assert_receive({:mix_shell, :info, [""" + + You must update :phoenix_live_view to v0.18 or later and + :phoenix_live_dashboard to v0.7 or later to use the features + in this generator. + """]}) + end + end + end + test "invalid mix arguments", config do in_tmp_live_project config.test, fn -> assert_raise Mix.Error, ~r/Expected the context, "blog", to be a valid module name/, fn -> diff --git a/test/mix/tasks/phx.gen.schema_test.exs b/test/mix/tasks/phx.gen.schema_test.exs index ea267f9274..feed83b7b5 100644 --- a/test/mix/tasks/phx.gen.schema_test.exs +++ b/test/mix/tasks/phx.gen.schema_test.exs @@ -3,17 +3,89 @@ Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ defmodule Phoenix.DupSchema do end +defmodule Phoenix.Test.CustomGen do + @behaviour Mix.Phoenix.CustomGeneratorBehaviour + def validate_attr!({name, __MODULE__, opts}) do + {name, {:custom, __MODULE__, opts}} + end + def type_and_opts_for_schema(_opts) do + ~s|:custom_schema_type| + end + def type_for_migration(_opts) do + :custom_ecto_type + end + def type_to_default(key, _opts, :create) do + "Special #{key}" + end + def type_to_default(key, _opts, :update) do + "Special Updated #{key}" + end + def live_form_input(key, _opts) do + "[SPECIAL INPUT HANDLER: #{key}]" + end + def hydrate_form_input(_key, params, _opts), do: params +end + defmodule Mix.Tasks.Phx.Gen.SchemaTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen alias Mix.Phoenix.Schema + @moduletag feature: :gen + @moduletag gen: :schema + setup do Mix.Task.clear() :ok end + describe "custom schema generator support" do + + + test "build with custom gen extension" do + in_tmp_project "build", fn -> + schema = Gen.Schema.build(~w(Blog.Post posts title:Phoenix.Test.CustomGen:option:list tags:map), []) + + assert %Schema{ + alias: Post, + module: Phoenix.Blog.Post, + repo: Phoenix.Repo, + migration?: true, + migration_defaults: %{title: ""}, + plural: "posts", + singular: "post", + human_plural: "Posts", + human_singular: "Post", + attrs: [title: {:custom, Phoenix.Test.CustomGen, :"option:list"}, tags: :map], + types: %{title: {:custom, Phoenix.Test.CustomGen, :"option:list"}, tags: :map}, + optionals: [:tags], + route_helper: "post", + defaults: %{title: "", tags: ""}, + } = schema + assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") + end + end + + test "generates schema with custom gen extension", config do + in_tmp_project config.test, fn -> + Gen.Schema.run(~w(Blog.CustomPost custom_blog_posts title:Phoenix.Test.CustomGen)) + + assert [migration] = Path.wildcard("priv/repo/migrations/*_create_custom_blog_posts.exs") + assert_file migration, fn file -> + assert file =~ "defmodule Phoenix.Repo.Migrations.CreateCustomBlogPosts do" + assert file =~ "create table(:custom_blog_posts) do" + assert file =~ "add :title, :custom_ecto_type" + end + + assert_file "lib/phoenix/blog/custom_post.ex", fn file -> + assert file =~ "defmodule Phoenix.Blog.CustomPost do" + assert file =~ "field :title, :custom_schema_type" + end + end + end + end + test "build" do in_tmp_project "build", fn -> schema = Gen.Schema.build(~w(Blog.Post posts title:string tags:map), [])