diff --git a/README.md b/README.md index 119da3d..24e9f84 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,12 @@ defmodule MyApp.Channel.SMS do end ``` -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 either contains a keyword list +mapping an atom representing the type (in this example `:email` and `:sms`) with the corresponding +embedded schema module, or specifies a function that can be used to map any type value into +a corresponding schema module. -There are two strategies to detect the right embedded schema to use: +There are four strategies to detect the right embedded schema to use: 1. ```elixir @@ -107,6 +109,50 @@ 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 +# Converts an arbritary type atom or string key to a schema module, e.g. "email" to MyApp.Channel.Email +def lookup_type(key, :module) do + case to_string(key) do + "sms" -> MyApp.Channel.SMS + _other -> Module.safe_concat(["MyApp.Channel", String.capitalize(key)]) + end +end + +# Reverse lookup, converts the module to the appropriate type +def lookup_type(key, :type) do + Module.split(key) + |> List.last() + |> String.downcase() + |> String.to_atom() +end + + +types: &MyModule.lookup_type/2 +``` + +If you specify `types: &MyModule.lookup_type/2` with the format `&Mod.fun/arity` as in the example above, +you supply a reference to a 2-arity function that converts both ways between types and modules. +The first argument to the function is the key to be converted, and the second argument is +either `:module` to convert from a type to a module, or `:key` to convert from a module to the type. +This allows you to embed arbitrary schemas without having to list +them all explicitly. The function should return an atom, not a string value. + +4. +```elixir +types: :by_module +``` + +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 +`types: &MyModule.lookup_type/2`, where `lookup_type` was defined as: + +```elixir +def lookup_type(key, :module), do: Module.safe_concat([to_string(key)]) +def lookup_type(key, :type), do: key +``` + + ### Options * `:types` - discussed above. diff --git a/lib/polymorphic_embed.ex b/lib/polymorphic_embed.ex index 40ef17f..7782f65 100644 --- a/lib/polymorphic_embed.ex +++ b/lib/polymorphic_embed.ex @@ -7,25 +7,37 @@ defmodule PolymorphicEmbed do @impl true def init(opts) do metadata = - Keyword.fetch!(opts, :types) - |> Enum.map(fn - {type_name, type_opts} when is_list(type_opts) -> - module = Keyword.fetch!(type_opts, :module) - identify_by_fields = Keyword.fetch!(type_opts, :identify_by_fields) - - %{ - type: type_name |> to_string(), - module: module, - identify_by_fields: identify_by_fields |> Enum.map(&to_string/1) - } - - {type_name, module} -> - %{ - type: type_name |> to_string(), - module: module, - identify_by_fields: [] - } - end) + case Keyword.get(opts, :types) do + types when is_list(types) -> + types + |> Enum.map(fn + {type_name, type_opts} when is_list(type_opts) -> + module = Keyword.fetch!(type_opts, :module) + identify_by_fields = Keyword.fetch!(type_opts, :identify_by_fields) + + %{ + type: type_name |> to_string(), + module: module, + identify_by_fields: identify_by_fields |> Enum.map(&to_string/1) + } + + {type_name, module} -> + %{ + type: type_name |> to_string(), + module: module, + identify_by_fields: [] + } + end) + + :by_module -> + %{lookup_type_fun: :by_module} + + type_fun when is_function(type_fun, 2) -> + %{lookup_type_fun: type_fun} + + _ -> + raise ArgumentError, ":types option must be list or :by_module or 2-arity function" + end %{ metadata: metadata, @@ -186,7 +198,7 @@ defmodule PolymorphicEmbed do defp do_get_polymorphic_module(%{"__type__" => type}, metadata), do: do_get_polymorphic_module(type, metadata) - defp do_get_polymorphic_module(%{} = attrs, metadata) do + defp do_get_polymorphic_module(%{} = attrs, metadata) when is_list(metadata) do # check if one list is contained in another # Enum.count(contained -- container) == 0 # contained -- container == [] @@ -196,7 +208,7 @@ defmodule PolymorphicEmbed do |> (&(&1 && Map.fetch!(&1, :module))).() end - defp do_get_polymorphic_module(type, metadata) do + defp do_get_polymorphic_module(type, metadata) when is_list(metadata) do type = to_string(type) metadata @@ -204,6 +216,17 @@ defmodule PolymorphicEmbed do |> (&(&1 && Map.fetch!(&1, :module))).() end + defp do_get_polymorphic_module(type, %{lookup_type_fun: :by_module}) do + type = to_string(type) + + Module.safe_concat([type]) + end + + defp do_get_polymorphic_module(type, %{lookup_type_fun: type_fun}) + when is_function(type_fun) do + type_fun.(type, :module) + end + def get_polymorphic_type(schema, field, module_or_struct) do %{metadata: metadata} = get_options(schema, field) do_get_polymorphic_type(module_or_struct, metadata) @@ -212,13 +235,21 @@ defmodule PolymorphicEmbed do defp do_get_polymorphic_type(%module{}, metadata), do: do_get_polymorphic_type(module, metadata) - defp do_get_polymorphic_type(module, metadata) do + defp do_get_polymorphic_type(module, metadata) when is_list(metadata) do metadata |> Enum.find(&(module == &1.module)) |> Map.fetch!(:type) |> String.to_atom() end + defp do_get_polymorphic_type(module, %{lookup_type_fun: :by_module}) do + module + end + + defp do_get_polymorphic_type(module, %{lookup_type_fun: type_fun}) when is_function(type_fun) do + type_fun.(module, :type) + end + defp get_options(schema, field) do try do schema.__schema__(:type, field) diff --git a/test/polymorphic_embed_test.exs b/test/polymorphic_embed_test.exs index d6178cf..beca793 100644 --- a/test/polymorphic_embed_test.exs +++ b/test/polymorphic_embed_test.exs @@ -64,6 +64,54 @@ defmodule PolymorphicEmbedTest do assert ~U[2020-05-28 07:27:05Z] == hd(reminder.channel.attempts).date end + test "with :by_module" do + sms_reminder_attrs = %{ + date: ~U[2020-05-28 02:57:19Z], + text: "This reminder has an SMS source", + source: %{ + __type__: "PolymorphicEmbed.Channel.SMS", + number: "02/807.05.53", + result: %{success: true}, + attempts: [ + %{ + date: ~U[2020-05-28 07:27:05Z], + result: %{success: true} + }, + %{ + date: ~U[2020-05-29 07:27:05Z], + result: %{success: false} + }, + %{ + date: ~U[2020-05-30 07:27:05Z], + result: %{success: true} + } + ], + provider: %{ + __type__: "twilio", + api_key: "foo" + } + } + } + + insert_result = + %Reminder{source: %SMS{country_code: 1}} + |> Reminder.changeset(sms_reminder_attrs) + |> Repo.insert() + + assert {:ok, %Reminder{}} = insert_result + + reminder = + Reminder + |> QueryBuilder.where(text: "This reminder has an SMS source") + |> Repo.one() + + assert SMS = reminder.source.__struct__ + assert TwilioSMSProvider = reminder.source.provider.__struct__ + assert SMSResult == reminder.source.result.__struct__ + assert true == reminder.source.result.success + assert ~U[2020-05-28 07:27:05Z] == hd(reminder.source.attempts).date + end + test "receive embed as struct" do reminder = %Reminder{ date: ~U[2020-05-28 02:57:19Z], @@ -100,6 +148,42 @@ defmodule PolymorphicEmbedTest do assert SMS = reminder.channel.__struct__ end + test "receive :by_module embed as struct" do + reminder = %Reminder{ + date: ~U[2020-05-28 02:57:19Z], + text: "This reminder has an SMS source", + source: %SMS{ + provider: %TwilioSMSProvider{ + api_key: "foo" + }, + country_code: 1, + number: "02/807.05.53", + result: %SMSResult{success: true}, + attempts: [ + %SMSAttempts{ + date: ~U[2020-05-28 07:27:05Z], + result: %SMSResult{success: true} + }, + %SMSAttempts{ + date: ~U[2020-05-28 07:27:05Z], + result: %SMSResult{success: true} + } + ] + } + } + + reminder + |> Reminder.changeset(%{}) + |> Repo.insert() + + reminder = + Reminder + |> QueryBuilder.where(text: "This reminder has an SMS source") + |> Repo.one() + + assert SMS = reminder.source.__struct__ + end + test "without __type__" do attrs = %{ date: ~U[2020-05-28 02:57:19Z], @@ -404,12 +488,36 @@ defmodule PolymorphicEmbedTest do }) == :email end + + test "returns the type for a :by_module type" do + assert PolymorphicEmbed.get_polymorphic_type(Reminder, :source, SMS) == SMS + end + + test "returns the type for a lookup type" do + assert PolymorphicEmbed.get_polymorphic_type(Reminder, :reference, SMS) == :sms + end end describe "get_polymorphic_module/3" do test "returns the module for a type" do assert PolymorphicEmbed.get_polymorphic_module(Reminder, :channel, :sms) == SMS end + + test "returns the module for a :by_module type with a string key" do + assert PolymorphicEmbed.get_polymorphic_module( + Reminder, + :source, + "PolymorphicEmbed.Channel.SMS" + ) == SMS + end + + test "returns the module for a :by_module type with an atom key" do + assert PolymorphicEmbed.get_polymorphic_module(Reminder, :source, SMS) == SMS + end + + test "returns the module for a lookup type" do + assert PolymorphicEmbed.get_polymorphic_module(Reminder, :reference, :sms) == SMS + end end defp safe_inputs_for(changeset, field, type, fun) do diff --git a/test/support/migrations/20000101100000_add_source_field.exs b/test/support/migrations/20000101100000_add_source_field.exs new file mode 100644 index 0000000..823a49a --- /dev/null +++ b/test/support/migrations/20000101100000_add_source_field.exs @@ -0,0 +1,10 @@ +defmodule PolymorphicEmbed.AddSourceField do + use Ecto.Migration + + def change do + alter table(:reminders) do + add(:source, :map) + add(:reference, :map) + end + end +end diff --git a/test/support/models/reminder.ex b/test/support/models/reminder.ex index 3c0fab0..2a0d638 100644 --- a/test/support/models/reminder.ex +++ b/test/support/models/reminder.ex @@ -18,6 +18,9 @@ defmodule PolymorphicEmbed.Reminder do ] ) + field(:source, PolymorphicEmbed, types: :by_module) + field(:reference, PolymorphicEmbed, types: &__MODULE__.lookup_type/2) + timestamps() end @@ -25,6 +28,17 @@ defmodule PolymorphicEmbed.Reminder do struct |> cast(values, [:date, :text]) |> cast_polymorphic_embed(:channel) + |> cast_polymorphic_embed(:source) |> validate_required(:date) end + + def lookup_type(key, :module) do + %{sms: PolymorphicEmbed.Channel.SMS, email: PolymorphicEmbed.Channel.Email} + |> Map.fetch!(key) + end + + def lookup_type(key, :type) do + %{PolymorphicEmbed.Channel.SMS => :sms, PolymorphicEmbed.Channel.Email => :email} + |> Map.fetch!(key) + end end