Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
75 changes: 53 additions & 22 deletions lib/polymorphic_embed.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 == []
Expand All @@ -196,14 +208,25 @@ 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
|> Enum.find(&(type == &1.type))
|> (&(&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)
Expand All @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions test/polymorphic_embed_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions test/support/migrations/20000101100000_add_source_field.exs
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions test/support/models/reminder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,27 @@ defmodule PolymorphicEmbed.Reminder do
]
)

field(:source, PolymorphicEmbed, types: :by_module)
field(:reference, PolymorphicEmbed, types: &__MODULE__.lookup_type/2)

timestamps()
end

def changeset(struct, values) 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