Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extending phx.gen.* functions to support custom generation type handlers. #5750

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions .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/
Expand Down
82 changes: 82 additions & 0 deletions 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
29 changes: 22 additions & 7 deletions lib/mix/phoenix/schema.ex
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/mix/tasks/phx.gen.live.ex
Expand Up @@ -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}),
Expand Down
24 changes: 24 additions & 0 deletions priv/templates/phx.gen.live/form_component.ex
Expand Up @@ -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)
Expand All @@ -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

Expand Down
164 changes: 164 additions & 0 deletions 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
Expand All @@ -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(<field={@form[:metadata]})
assert file =~ """
<.input
field={@form[:status]}
type="select"
"""
assert file =~ ~s|Ecto.Enum.values(Phoenix.Blog.Post, :status)|

assert file =~ ~s|post_params = Phoenix.Test.LiveCustomGen.hydrate_form_input(:title, post_params, [])|

refute file =~ ~s(<.input field={@form[:user_id]})
end

assert_file "test/phoenix_web/live/post_live_test.exs", fn file ->
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 ->
Expand Down