diff --git a/README.md b/README.md index ef3c9b7..57f4811 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,9 @@ changeset ### PolymorphicEmbed Ecto type -The `:types` option for the `PolymorphicEmbed` custom type contains a keyword list mapping an atom representing the type -(in this example `:email` and `:sms`) with the corresponding embedded schema module. +The `:types` option for the `PolymorphicEmbed` custom type is used to determine the type of the polymorphic embed. The value is either a keyword list mapping an atom representing the type (in this example `:email` and `:sms`) with the corresponding embedded schema module, or the value :by_module which indicates that the struct will be passed in at runtime. -There are two strategies to detect the right embedded schema to use: +There are three strategies to detect the right embedded schema to use: 1. ```elixir @@ -137,6 +136,16 @@ parameter is then no longer required. Note that you may still include a `__type__` parameter that will take precedence over this strategy (this could still be useful if you need to store incomplete data, which might not allow identifying the type). +3. +```elixir +polymorphic_embeds_many :attachment, + types: :by_module, + on_replace: :update +``` + +The `:by_module` value indicates that this field will receive an embedded_struct in the `type_field` (by default `__type__`) which is used to cash the embed. +If you specify `types: :by_module`, the `"__type__"` (or `:__type__`) parameter should contain the fully qualified and reachable module name of the embedded schema, such as "MyApp.Channel.Email". This is equivalent to specifying + #### List of polymorphic embeds Lists of polymorphic embeds are also supported: diff --git a/lib/polymorphic_embed.ex b/lib/polymorphic_embed.ex index 16b98a1..079e672 100644 --- a/lib/polymorphic_embed.ex +++ b/lib/polymorphic_embed.ex @@ -29,22 +29,29 @@ defmodule PolymorphicEmbed do types_metadata = opts |> Keyword.fetch!(:types) - |> Enum.map(fn - {type_name, type_opts} when is_list(type_opts) -> - {type_name, type_opts} - - {type_name, module} -> - {type_name, module: module} - end) - |> Enum.map(fn - {type_name, type_opts} -> - %{ - type: type_name, - module: Keyword.fetch!(type_opts, :module), - identify_by_fields: - type_opts |> Keyword.get(:identify_by_fields, []) |> Enum.map(&to_string/1) - } - end) + |> case do + :by_module -> + %{lookup_type_fun: :by_module} + + types when is_list(types) -> + types + |> Enum.map(fn + {type_name, type_opts} when is_list(type_opts) -> + {type_name, type_opts} + + {type_name, module} -> + {type_name, module: module} + end) + |> Enum.map(fn + {type_name, type_opts} -> + %{ + type: type_name, + module: Keyword.fetch!(type_opts, :module), + identify_by_fields: + type_opts |> Keyword.get(:identify_by_fields, []) |> Enum.map(&to_string/1) + } + end) + end %{ default: Keyword.get(opts, :default, nil), @@ -65,6 +72,7 @@ defmodule PolymorphicEmbed do %{array?: array?, types_metadata: types_metadata} = field_options required = Keyword.get(cast_options, :required, false) + with = Keyword.get(cast_options, :with, nil) changeset_fun = fn @@ -84,6 +92,13 @@ defmodule PolymorphicEmbed do fun -> apply(fun, [struct, params]) end + + struct, params when is_tuple(with) and tuple_size(with) == 3 -> + {module, function_name, args} = with + apply(module, function_name, [struct, params | args]) + + struct, params when is_function(with) -> + apply(with, [struct, params]) end (changeset.params || %{}) @@ -339,12 +354,25 @@ defmodule PolymorphicEmbed do # Enum.count(contained -- container) == 0 # contained -- container == [] types_metadata - |> Enum.filter(&([] != &1.identify_by_fields)) - |> Enum.find(&([] == &1.identify_by_fields -- Map.keys(attrs))) - |> (&(&1 && Map.fetch!(&1, :module))).() + |> case do + %{lookup_type_fun: :by_module} -> + nil + + _ -> + types_metadata + |> Enum.filter(&([] != &1.identify_by_fields)) + |> Enum.find(&([] == &1.identify_by_fields -- Map.keys(attrs))) + |> (&(&1 && Map.fetch!(&1, :module))).() + end end end + defp do_get_polymorphic_module_for_type(type, %{lookup_type_fun: :by_module}) do + type = to_string(type) + + Module.safe_concat([type]) + end + defp do_get_polymorphic_module_for_type(type, types_metadata) do get_metadata_for_type(type, types_metadata) |> (&(&1 && Map.fetch!(&1, :module))).() @@ -355,6 +383,14 @@ defmodule PolymorphicEmbed do do_get_polymorphic_type(module_or_struct, types_metadata) end + defp do_get_polymorphic_type(%module{}, %{lookup_type_fun: :by_module}), + do: do_get_polymorphic_type(module, %{lookup_type_fun: :by_module}) + + defp do_get_polymorphic_type(module, %{lookup_type_fun: :by_module}) do + module = to_string(module) + Module.safe_concat([module]) + end + defp do_get_polymorphic_type(%module{}, types_metadata), do: do_get_polymorphic_type(module, types_metadata) @@ -372,7 +408,7 @@ defmodule PolymorphicEmbed do """ def types(schema, field) do %{types_metadata: types_metadata} = get_field_options(schema, field) - Enum.map(types_metadata, &(&1.type)) + Enum.map(types_metadata, & &1.type) end defp get_metadata_for_module(module, types_metadata) do @@ -412,6 +448,11 @@ defmodule PolymorphicEmbed do end end + defp convert_map_keys_to_string(%_{} = struct) do + Map.from_struct(struct) + |> convert_map_keys_to_string() + end + defp convert_map_keys_to_string(%{} = map), do: for({key, val} <- map, into: %{}, do: {to_string(key), val}) diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs index b27e5ab..7c2a1b4 100644 --- a/test/polymorphic_embed_test.exs +++ b/test/polymorphic_embed_test.exs @@ -23,13 +23,18 @@ defmodule PolymorphicEmbedTest do defp get_module(name, :not_polymorphic), do: Module.concat([PolymorphicEmbed.Regular, name]) - test "receive embed as map of values" do + test "receive embedded as a map of values" do for generator <- @generators do reminder_module = get_module(Reminder, generator) sms_reminder_attrs = %{ date: ~U[2020-05-28 02:57:19Z], text: "This is an SMS reminder #{generator}", + attachment: %{ + __type__: "PolymorphicEmbed.Attachment.VideoAttachment", + url: "some_video_url", + thumbnail_url: "some_video_thumbnail_url" + }, channel: %{ my_type_field: "sms", number: "02/807.05.53", @@ -70,6 +75,8 @@ defmodule PolymorphicEmbedTest do assert get_module(Channel.SMS, generator) == reminder.channel.__struct__ + assert PolymorphicEmbed.Attachment.VideoAttachment == reminder.attachment.__struct__ + assert get_module(Channel.TwilioSMSProvider, generator) == reminder.channel.provider.__struct__ @@ -119,6 +126,66 @@ defmodule PolymorphicEmbedTest do end end + test "receive embedded as a map of values with different dynamic types" do + reminder_module = get_module(Reminder, :polymorphic) + + # With Link + reminder_attrs = %{ + date: ~U[2020-05-28 02:57:19Z], + text: "This is an SMS reminder with link #{:polymorphic}", + attachment: %{ + __type__: PolymorphicEmbed.Attachment.LinkAttachment, + url: "some_link", + title: "title_for_link" + } + } + + insert_result = + struct(reminder_module) + |> reminder_module.changeset(reminder_attrs) + |> Repo.insert() + + assert {:ok, %reminder_module{}} = insert_result + + reminder = + reminder_module + |> QueryBuilder.where(text: "This is an SMS reminder with link #{:polymorphic}") + |> Repo.one() + + assert %PolymorphicEmbed.Attachment.LinkAttachment{ + url: "some_link", + title: "title_for_link" + } = reminder.attachment + + # With Video + reminder_attrs = %{ + date: ~U[2020-05-28 02:57:19Z], + text: "This is an SMS reminder with video #{:polymorphic}", + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + url: "some_video_url", + thumbnail_url: "some_video_thumbnail_url" + } + } + + insert_result = + struct(reminder_module) + |> reminder_module.changeset(reminder_attrs) + |> Repo.insert() + + assert {:ok, %reminder_module{}} = insert_result + + reminder = + reminder_module + |> QueryBuilder.where(text: "This is an SMS reminder with video #{:polymorphic}") + |> Repo.one() + + assert %PolymorphicEmbed.Attachment.VideoAttachment{ + url: "some_video_url", + thumbnail_url: "some_video_thumbnail_url" + } = reminder.attachment + end + test "validations before casting polymorphic embed still work" do for generator <- @generators do reminder_module = get_module(Reminder, generator) @@ -158,6 +225,10 @@ defmodule PolymorphicEmbedTest do text: "This is an SMS reminder", channel: %{ my_type_field: "sms" + }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + thumbnail_url: "the thumbnail url" } } @@ -176,6 +247,11 @@ defmodule PolymorphicEmbedTest do action: :insert, valid?: false, errors: channel_errors + }, + attachment: %{ + action: :insert, + valid?: false, + errors: attachment_errors } } }} = insert_result @@ -187,6 +263,10 @@ defmodule PolymorphicEmbedTest do country_code: {"can't be blank", [validation: :required]}, provider: {"can't be blank", [validation: :required]} } = Map.new(channel_errors) + + assert %{ + url: {"can't be blank", [validation: :required]} + } = Map.new(attachment_errors) end end @@ -199,6 +279,10 @@ defmodule PolymorphicEmbedTest do channel: %{ my_type_field: "sms" }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + thumbnail_url: "thumbnail url" + }, contexts: [ %{ __type__: "location", @@ -231,6 +315,11 @@ defmodule PolymorphicEmbedTest do valid?: false, errors: channel_errors }, + attachment: %{ + action: :insert, + valid?: false, + errors: attachment_errors + }, contexts: [ %{ action: :insert, @@ -259,6 +348,8 @@ defmodule PolymorphicEmbedTest do provider: {"can't be blank", [validation: :required]} } = Map.new(channel_errors) + assert %{url: {"can't be blank", [validation: :required]}} = Map.new(attachment_errors) + assert [date: {"can't be blank", [validation: :required]}] = changeset.errors assert [date: {"can't be blank", [validation: :required]}] = errors @@ -279,6 +370,9 @@ defmodule PolymorphicEmbedTest do number: ["can't be blank"], provider: ["can't be blank"] }, + attachment: %{ + url: ["can't be blank"] + }, contexts: [%{country: %{name: ["can't be blank"]}}, %{address: ["can't be blank"]}], date: ["can't be blank"] } = @@ -305,6 +399,11 @@ defmodule PolymorphicEmbedTest do country_code: 1, provider: %{__type__: "twilio", api_key: "somekey"} }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + thumbnail_url: "thumbnail url", + url: "the url" + }, contexts: [ %{ __type__: "location", @@ -414,7 +513,12 @@ defmodule PolymorphicEmbedTest do result: struct(sms_result_module, success: true) ) ] - ) + ), + attachment: + struct(PolymorphicEmbed.Attachment.VideoAttachment, %{ + url: "the url", + thumbnail_url: "the thumbnail url" + }) ) reminder @@ -427,10 +531,11 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert sms_module == reminder.channel.__struct__ + assert PolymorphicEmbed.Attachment.VideoAttachment == reminder.attachment.__struct__ changeset = reminder - |> reminder_module.changeset(%{channel: %{provider: nil}}) + |> reminder_module.changeset(%{channel: %{provider: nil}, attachment: %{url: nil}}) assert %Ecto.Changeset{ action: nil, @@ -441,6 +546,11 @@ defmodule PolymorphicEmbedTest do action: :update, valid?: false, errors: [provider: {"can't be blank", [validation: :required]}] + }, + attachment: %{ + action: :update, + valid?: false, + errors: [url: {"can't be blank", [validation: :required]}] } } } = changeset @@ -459,15 +569,54 @@ defmodule PolymorphicEmbedTest do action: :update, valid?: false, errors: channel_errors + }, + attachment: %{ + action: :update, + valid?: false, + errors: attachment_errors } } }} = insert_result assert [] = errors assert %{provider: {"can't be blank", [validation: :required]}} = Map.new(channel_errors) + assert %{url: {"can't be blank", [validation: :required]}} = Map.new(attachment_errors) end end + test "receive embed as struct when types is :by_module" do + reminder_module = get_module(Reminder, :polymorphic) + + reminder = + struct(reminder_module, + date: ~U[2020-05-28 02:57:19Z], + text: "This is an SMS reminder with video.", + attachment: + struct(PolymorphicEmbed.Attachment.VideoAttachment, %{ + url: "some_video_url", + thumbnail_url: "some_thumbnail_url" + }) + ) + + reminder + |> reminder_module.changeset(%{}) + |> Repo.insert() + + reminder = + reminder_module + |> QueryBuilder.where(text: "This is an SMS reminder with video.") + |> Repo.one() + + assert PolymorphicEmbed.Attachment.VideoAttachment == reminder.attachment.__struct__ + + changeset = reminder_module.changeset(reminder, %{attachment: %{url: nil}}) + + refute changeset.valid? + + assert changeset.changes.attachment.errors[:url] == + {"can't be blank", [validation: :required]} + end + test "cannot generate IDs if struct didn't go through cast_polymorphic_embed/3" do generator = :polymorphic @@ -522,6 +671,29 @@ defmodule PolymorphicEmbedTest do assert email_module == reminder.channel.__struct__ end + test "without __type__ when types: :by_module" do + generator = :polymorphic + reminder_module = get_module(Reminder, generator) + + attrs = %{ + date: ~U[2020-05-28 02:57:19Z], + text: "This is a reminder with an attachment", + attachment: %{ + url: "the url", + thumbnail_url: "the thumbnail_url" + } + } + + insert_result = + struct(reminder_module) + |> reminder_module.changeset(attrs) + |> Repo.insert() + + assert {:error, changeset} = insert_result + + assert %{attachment: {"is invalid", []}} = Map.new(changeset.errors) + end + test "wrong type as string adds error in changeset" do generator = :polymorphic reminder_module = get_module(Reminder, generator) @@ -531,6 +703,9 @@ defmodule PolymorphicEmbedTest do text: "This is an Email reminder", channel: %{ my_type_field: "unknown type" + }, + attachment: %{ + __type___: "UnknownType" } } @@ -539,7 +714,8 @@ defmodule PolymorphicEmbedTest do |> reminder_module.changeset(attrs) |> Repo.insert() - assert {:error, %Ecto.Changeset{errors: [channel: {"is invalid", []}]}} = insert_result + assert {:error, %Ecto.Changeset{errors: errors}} = insert_result + assert %{channel: {"is invalid", []}, attachment: {"is invalid", []}} = Map.new(errors) end test "wrong type as string raises" do @@ -574,26 +750,36 @@ defmodule PolymorphicEmbedTest do reminder_module = get_module(Reminder, generator) - assert_raise RuntimeError, ~r"cast_polymorphic_embed/3 only accepts a changeset as first argument", fn -> - PolymorphicEmbed.cast_polymorphic_embed(struct(reminder_module), :channel) - end + assert_raise RuntimeError, + ~r"cast_polymorphic_embed/3 only accepts a changeset as first argument", + fn -> + PolymorphicEmbed.cast_polymorphic_embed(struct(reminder_module), :channel) + end + + assert_raise RuntimeError, + ~r"cast_polymorphic_embed/3 only accepts a changeset as first argument", + fn -> + PolymorphicEmbed.cast_polymorphic_embed(struct(reminder_module), :attachment) + end end test "cast embed after change/2 call should succeed" do for generator <- @generators do - reminder_module = get_module(Reminder, generator) + for field <- [:channel, :attachment] do + reminder_module = get_module(Reminder, generator) - changeset = Ecto.Changeset.change(struct(reminder_module)) + changeset = Ecto.Changeset.change(struct(reminder_module)) - changeset = - if polymorphic?(generator) do - PolymorphicEmbed.cast_polymorphic_embed(changeset, :channel) - else - Ecto.Changeset.cast_embed(changeset, :channel) - end + changeset = + if polymorphic?(generator) do + PolymorphicEmbed.cast_polymorphic_embed(changeset, field) + else + Ecto.Changeset.cast_embed(changeset, field) + end - assert changeset.valid? - assert map_size(changeset.changes) == 0 + assert changeset.valid? + assert map_size(changeset.changes) == 0 + end end end @@ -605,7 +791,8 @@ defmodule PolymorphicEmbedTest do struct(reminder_module, date: ~U[2020-05-28 02:57:19Z], text: "This is an Email reminder #{generator}", - channel: nil + channel: nil, + attachment: nil ) |> Repo.insert() @@ -617,6 +804,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert is_nil(reminder.channel) + assert is_nil(reminder.attachment) end end @@ -627,7 +815,8 @@ defmodule PolymorphicEmbedTest do attrs = %{ date: ~U[2020-05-28 02:57:19Z], text: "This is an Email reminder #{generator}", - channel: nil + channel: nil, + attachment: nil } insert_result = @@ -643,6 +832,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert is_nil(reminder.channel) + assert is_nil(reminder.attachment) end end @@ -683,6 +873,7 @@ defmodule PolymorphicEmbedTest do test "custom changeset by passing MFA" do for generator <- @generators do reminder_module = get_module(Reminder, generator) + sms_module = get_module(Channel.SMS, generator) sms_reminder_attrs = %{ @@ -695,6 +886,11 @@ defmodule PolymorphicEmbedTest do attempts: [], provider: %{__type__: "twilio", api_key: "somekey"}, custom: true + }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + url: "the url", + custom: true } } @@ -714,6 +910,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert sms_module == reminder.channel.__struct__ + assert PolymorphicEmbed.Attachment.VideoAttachment == reminder.attachment.__struct__ end end @@ -732,6 +929,11 @@ defmodule PolymorphicEmbedTest do attempts: [], provider: %{__type__: "twilio", api_key: "somekey"}, custom: true + }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + url: "the url", + custom: true } } @@ -742,6 +944,7 @@ defmodule PolymorphicEmbedTest do assert {:ok, reminder} = insert_result assert reminder.channel.custom + assert reminder.attachment.custom %reminder_module{} = reminder @@ -751,6 +954,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert sms_module == reminder.channel.__struct__ + assert PolymorphicEmbed.Attachment.VideoAttachment == reminder.attachment.__struct__ end end @@ -795,7 +999,8 @@ defmodule PolymorphicEmbedTest do attrs = %{ date: ~U[2020-05-28 02:57:19Z], text: "This is an SMS reminder #{generator}", - channel: nil + channel: nil, + attachment: nil } insert_result = @@ -804,7 +1009,8 @@ defmodule PolymorphicEmbedTest do struct(sms_module, number: "02/807.05.53", country_code: 32 - ) + ), + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, %{url: "the url"}) ) |> reminder_module.changeset(attrs) |> Repo.insert() @@ -817,6 +1023,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert is_nil(reminder.channel) + assert is_nil(reminder.attachment) end end @@ -835,6 +1042,10 @@ defmodule PolymorphicEmbedTest do channel: struct(sms_module, number: "02/807.05.53" + ), + attachment: + struct(PolymorphicEmbed.Attachment.VideoAttachment, + url: "the url" ) ) |> reminder_module.changeset(attrs) @@ -848,6 +1059,7 @@ defmodule PolymorphicEmbedTest do |> Repo.one() refute is_nil(reminder.channel) + refute is_nil(reminder.attachment) end end @@ -882,7 +1094,8 @@ defmodule PolymorphicEmbedTest do result: struct(sms_result_module, success: true) ) ] - ) + ), + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, url: "the url") ) reminder = @@ -895,6 +1108,9 @@ defmodule PolymorphicEmbedTest do |> reminder_module.changeset(%{ channel: %{ number: "54" + }, + attachment: %{ + thumbnail_url: "the thumbnail url" } }) @@ -906,6 +1122,8 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert reminder.channel.result.success + assert reminder.attachment.url == "the url" + assert reminder.attachment.thumbnail_url == "the thumbnail url" end end @@ -940,7 +1158,8 @@ defmodule PolymorphicEmbedTest do result: struct(sms_result_module, success: true) ) ] - ) + ), + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, url: "the original_url") ) reminder = @@ -954,6 +1173,11 @@ defmodule PolymorphicEmbedTest do "channel" => %{ "my_type_field" => "sms", "number" => "54" + }, + "attachment" => %{ + "__type__" => "PolymorphicEmbed.Attachment.VideoAttachment", + "url" => "the new url", + "thumbnail_url" => "thumbnail url" } }) @@ -965,6 +1189,8 @@ defmodule PolymorphicEmbedTest do |> Repo.one() assert reminder.channel.result.success + assert reminder.attachment.url == "the new url" + assert reminder.attachment.thumbnail_url == "thumbnail url" end end @@ -997,6 +1223,9 @@ defmodule PolymorphicEmbedTest do __type__: "twilio", api_key: "foo" } + }, + attachment: %{ + url: "the url" } } @@ -1005,7 +1234,9 @@ defmodule PolymorphicEmbedTest do |> reminder_module.changeset(sms_reminder_attrs) |> Repo.insert() - assert {:error, %Ecto.Changeset{errors: [channel: {"is invalid", []}]}} = insert_result + assert {:error, %Ecto.Changeset{errors: errors}} = insert_result + + assert %{channel: {"is invalid", []}, attachment: {"is invalid", []}} = Map.new(errors) end test "missing __type__ nilifies" do @@ -1103,7 +1334,8 @@ defmodule PolymorphicEmbedTest do struct(sms_module, country_code: 1, number: "02/807.05.53" - ) + ), + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, url: "the url") ) |> reminder_module.changeset(%{}) |> Repo.insert() @@ -1121,6 +1353,31 @@ defmodule PolymorphicEmbedTest do end end + test "cannot load the right struct with :by_module" do + generator = :polymorphic + reminder_module = get_module(Reminder, generator) + + struct(reminder_module, + date: ~U[2020-05-28 02:57:19Z], + text: "This is an SMS reminder", + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, url: "the url") + ) + |> reminder_module.changeset(%{}) + |> Repo.insert() + + Ecto.Adapters.SQL.query!( + Repo, + "UPDATE reminders SET channel = jsonb_set(attachment, '{__type__}', '\"bar\"')", + [] + ) + + assert_raise RuntimeError, ~r"could not infer polymorphic embed from data .* \"bar\"", fn -> + reminder_module + |> QueryBuilder.where(text: "This is an SMS reminder") + |> Repo.one() + end + end + test "changing type" do generator = :polymorphic reminder_module = get_module(Reminder, generator) @@ -1270,12 +1527,15 @@ defmodule PolymorphicEmbedTest do struct(reminder_module, date: ~U[2020-05-28 02:57:19Z], text: "This is an SMS reminder #{generator}", - channel: struct(sms_module)) + channel: struct(sms_module), + attachment: struct(PolymorphicEmbed.Attachment.VideoAttachment, url: "the url") + ) changeset = reminder_module.changeset(struct, %{}) if polymorphic?(generator) do assert changeset.changes.channel.id + assert changeset.changes.attachment.id else assert map_size(changeset.changes) == 0 end @@ -1284,8 +1544,10 @@ defmodule PolymorphicEmbedTest do if polymorphic?(generator) do assert changeset.changes.channel.id == struct.channel.id + assert changeset.changes.attachment.id == struct.attachment.id else assert struct.channel.id + assert struct.attachment.id end end end @@ -1297,7 +1559,8 @@ defmodule PolymorphicEmbedTest do struct = struct(reminder_module, date: ~U[2020-05-28 02:57:19Z], - text: "This is an SMS reminder #{generator}") + text: "This is an SMS reminder #{generator}" + ) changeset = reminder_module.changeset( @@ -1311,21 +1574,30 @@ defmodule PolymorphicEmbedTest do __type__: "twilio", api_key: "foo" } + }, + attachment: %{ + __type__: PolymorphicEmbed.Attachment.VideoAttachment, + url: "the url" } - }) + } + ) if polymorphic?(generator) do assert changeset.changes.channel.id + assert changeset.changes.attachment.id else refute Map.has_key?(changeset.changes.channel, :id) + refute Map.has_key?(changeset.changes.attachment, :id) end struct = Repo.insert!(changeset) if polymorphic?(generator) do assert changeset.changes.channel.id == struct.channel.id + assert changeset.changes.attachment.id == struct.attachment.id else assert struct.channel.id + assert struct.attachment.id end end end @@ -1342,7 +1614,8 @@ defmodule PolymorphicEmbedTest do contexts: [ struct(location_module), struct(location_module) - ]) + ] + ) changeset = reminder_module.changeset(struct, %{}) @@ -1372,7 +1645,8 @@ defmodule PolymorphicEmbedTest do struct = struct(reminder_module, date: ~U[2020-05-28 02:57:19Z], - text: "This is an SMS reminder #{generator}") + text: "This is an SMS reminder #{generator}" + ) changeset = reminder_module.changeset( @@ -1382,7 +1656,8 @@ defmodule PolymorphicEmbedTest do %{__type__: "location", address: "A"}, %{__type__: "location", address: "B"} ] - }) + } + ) if polymorphic?(generator) do assert Enum.at(changeset.changes.contexts, 0).id @@ -1584,6 +1859,10 @@ defmodule PolymorphicEmbedTest do address: "a", valid: true, confirmed: true + }, + attachment: %{ + __type__: "PolymorphicEmbed.Attachment.VideoAttachment", + url: "the url" } } @@ -1592,6 +1871,7 @@ defmodule PolymorphicEmbedTest do |> struct() |> reminder_module.changeset(attrs) + # Field channel html = render_component( &liveview_form/1, @@ -1605,6 +1885,24 @@ defmodule PolymorphicEmbedTest do assert [input] = Floki.find(html, "#reminder_channel_number") assert Floki.attribute(input, "type") == ["text"] + + # Field attachment + html = + render_component( + &liveview_form/1, + %{changeset: changeset, field: :attachment, embedded_fields: :url} + ) + |> Floki.parse_fragment!() + + assert [input] = Floki.find(html, "#reminder_attachment___type__") + assert Floki.attribute(input, "type") == ["hidden"] + + assert Floki.attribute(input, "value") == [ + "Elixir.PolymorphicEmbed.Attachment.VideoAttachment" + ] + + assert [input] = Floki.find(html, "#reminder_attachment_url") + assert Floki.attribute(input, "type") == ["text"] end end @@ -1619,6 +1917,9 @@ defmodule PolymorphicEmbedTest do address: "a", valid: true, confirmed: true + }, + attachment: %{ + url: "the url" } } @@ -1643,6 +1944,29 @@ defmodule PolymorphicEmbedTest do assert contents == expected_contents + attachment_contents = + safe_inputs_for( + changeset, + :attachment, + PolymorphicEmbed.Attachment.VideoAttachment, + generator, + fn f -> + assert f.impl == Phoenix.HTML.FormData.Ecto.Changeset + assert f.errors == [] + text_input(f, :url) + end + ) + + expected_attachment_contents = + if(polymorphic?(generator), + do: + ~s(), + else: + ~s() + ) + + assert attachment_contents == expected_attachment_contents + contents = safe_inputs_for( Map.put(changeset, :action, :insert), @@ -2034,6 +2358,14 @@ defmodule PolymorphicEmbedTest do } ) == :email end + + test "returns the module for a :by_module embed" do + assert PolymorphicEmbed.get_polymorphic_type( + PolymorphicEmbed.Reminder, + :attachment, + %PolymorphicEmbed.Attachment.VideoAttachment{} + ) == PolymorphicEmbed.Attachment.VideoAttachment + end end describe "Form.get_polymorphic_type/3" do @@ -2198,7 +2530,13 @@ defmodule PolymorphicEmbedTest do fn f -> inputs_for(f, field, fun) end :polymorphic -> - fn f -> polymorphic_embed_inputs_for(f, field, fun) end + fn f -> + if embed_type do + polymorphic_embed_inputs_for(f, field, embed_type, fun) + else + polymorphic_embed_inputs_for(f, field, fun) + end + end :polymorphic_with_type -> fn f -> polymorphic_embed_inputs_for(f, field, embed_type, fun) end @@ -2220,6 +2558,9 @@ defmodule PolymorphicEmbedTest do end defp liveview_form(assigns) do + assigns = Map.put_new(assigns, :embedded_fields, [:number]) + # embedded_fields = (assigns[:embedded_fields] || :number) |> List.wrap() + ~H""" <.form let={f} @@ -2227,7 +2568,9 @@ defmodule PolymorphicEmbedTest do > <%= for sms_form <- polymorphic_embed_inputs_for f, @field do %> <%= hidden_inputs_for(sms_form) %> - <%= text_input sms_form, :number %> + <%= for field <- List.wrap(@embedded_fields) do %> + <%= text_input sms_form, field %> + <% end %> <% end %> """ diff --git a/test/support/migrations/20000101000001_add_attachments.exs b/test/support/migrations/20000101000001_add_attachments.exs new file mode 100644 index 0000000..cd4e9e7 --- /dev/null +++ b/test/support/migrations/20000101000001_add_attachments.exs @@ -0,0 +1,9 @@ +defmodule PolymorphicEmbed.AddAttachments do + use Ecto.Migration + + def change do + alter table(:reminders) do + add(:attachment, :map) + end + end +end diff --git a/test/support/models/not_polymorphic/attachment/link_attachment.ex b/test/support/models/not_polymorphic/attachment/link_attachment.ex new file mode 100644 index 0000000..5406665 --- /dev/null +++ b/test/support/models/not_polymorphic/attachment/link_attachment.ex @@ -0,0 +1,14 @@ +defmodule PolymorphicEmbed.Attachment.LinkAttachment do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :url, :string + field :title, :string + end + + def changeset(struct, params) do + struct + |> cast(params, [:url, :title]) + end +end diff --git a/test/support/models/not_polymorphic/attachment/video_attachment.ex b/test/support/models/not_polymorphic/attachment/video_attachment.ex new file mode 100644 index 0000000..72f8c75 --- /dev/null +++ b/test/support/models/not_polymorphic/attachment/video_attachment.ex @@ -0,0 +1,28 @@ +defmodule PolymorphicEmbed.Attachment.VideoAttachment do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :url, :string + field :thumbnail_url, :string + field :custom, :boolean, default: false + end + + def changeset(struct, params) do + struct + |> cast(params, [:url, :thumbnail_url]) + |> validate_required([:url]) + end + + def custom_changeset(struct, attrs, _foo, _bar) do + struct + |> changeset(attrs) + |> cast(attrs, [:custom]) + end + + def custom_changeset2(struct, attrs) do + struct + |> changeset(attrs) + |> cast(attrs, [:custom]) + end +end diff --git a/test/support/models/not_polymorphic/reminder.ex b/test/support/models/not_polymorphic/reminder.ex index 026b6da..adb4643 100644 --- a/test/support/models/not_polymorphic/reminder.ex +++ b/test/support/models/not_polymorphic/reminder.ex @@ -8,6 +8,7 @@ defmodule PolymorphicEmbed.Regular.Reminder do field(:text, :string) embeds_one(:channel, PolymorphicEmbed.Regular.Channel.SMS, on_replace: :update) + embeds_one(:attachment, PolymorphicEmbed.Attachment.VideoAttachment, on_replace: :update) embeds_many(:contexts, PolymorphicEmbed.Regular.Reminder.Context.Location, on_replace: :delete) @@ -19,6 +20,7 @@ defmodule PolymorphicEmbed.Regular.Reminder do |> cast(values, [:date, :text]) |> validate_required(:date) |> cast_embed(:channel) + |> cast_embed(:attachment) |> cast_embed(:contexts) end @@ -28,6 +30,9 @@ defmodule PolymorphicEmbed.Regular.Reminder do |> cast_embed(:channel, with: {PolymorphicEmbed.Regular.Channel.SMS, :custom_changeset, ["foo", "bar"]} ) + |> cast_embed(:attachment, + with: {PolymorphicEmbed.Attachment.VideoAttachment, :custom_changeset, ["foo", "bar"]} + ) |> validate_required(:date) end @@ -35,6 +40,9 @@ defmodule PolymorphicEmbed.Regular.Reminder do struct |> cast(values, [:date, :text]) |> cast_embed(:channel, with: &PolymorphicEmbed.Regular.Channel.SMS.custom_changeset2/2) + |> cast_embed(:attachment, + with: &PolymorphicEmbed.Attachment.VideoAttachment.custom_changeset2/2 + ) |> validate_required(:date) end end diff --git a/test/support/models/polymorphic/channel/email.ex b/test/support/models/polymorphic/channel/email.ex index b1d7ebc..e79d25f 100644 --- a/test/support/models/polymorphic/channel/email.ex +++ b/test/support/models/polymorphic/channel/email.ex @@ -13,7 +13,8 @@ defmodule PolymorphicEmbed.Channel.Email do def changeset(email, params) do email |> cast(params, ~w(address confirmed valid)a) - |> validate_required(:address) - |> validate_length(:address, min: 3) + + # |> validate_required(:address) + # |> validate_length(:address, min: 3) end end diff --git a/test/support/models/polymorphic/reminder.ex b/test/support/models/polymorphic/reminder.ex index 520f73b..8a3b825 100644 --- a/test/support/models/polymorphic/reminder.ex +++ b/test/support/models/polymorphic/reminder.ex @@ -39,6 +39,8 @@ defmodule PolymorphicEmbed.Reminder do on_replace: :delete ) + polymorphic_embeds_one(:attachment, types: :by_module, on_replace: :update) + timestamps() end @@ -47,6 +49,7 @@ defmodule PolymorphicEmbed.Reminder do |> cast(values, [:date, :text]) |> validate_required(:date) |> cast_polymorphic_embed(:channel) + |> cast_polymorphic_embed(:attachment) |> cast_polymorphic_embed(:contexts) |> cast_polymorphic_embed(:contexts2) end @@ -60,6 +63,9 @@ defmodule PolymorphicEmbed.Reminder do email: {PolymorphicEmbed.Channel.Email, :custom_changeset, ["foo", "bar"]} ] ) + |> cast_polymorphic_embed(:attachment, + with: {PolymorphicEmbed.Attachment.VideoAttachment, :custom_changeset, ["foo", "bar"]} + ) |> validate_required(:date) end @@ -71,17 +77,20 @@ defmodule PolymorphicEmbed.Reminder do sms: &PolymorphicEmbed.Channel.SMS.custom_changeset2/2 ] ) - |> validate_required(:date) - end - - def custom_changeset3(struct, values) do - struct - |> cast(values, [:date, :text]) - |> cast_polymorphic_embed(:channel, - with: [ - sms: &PolymorphicEmbed.Channel.SMS.custom_changeset2/2 - ] + |> cast_polymorphic_embed(:attachment, + with: &PolymorphicEmbed.Attachment.VideoAttachment.custom_changeset2/2 ) |> validate_required(:date) end + + # def custom_changeset3(struct, values) do + # struct + # |> cast(values, [:date, :text]) + # |> cast_polymorphic_embed(:channel, + # with: [ + # sms: &PolymorphicEmbed.Channel.SMS.custom_changeset2/2 + # ] + # ) + # |> validate_required(:date) + # end end