defmodule EctoPoly do @moduledoc """ Creates a polymorphic embedded type """ @doc """ # Arguments * `types`: Keyword list of `{name, type}`. The `name` is stored in the database in order to identify what `type` to use in runtime. * `type_field`: Name of the field used to the type of that particular object. Default is `:__type__`. # Example defmodule EctoPoly.TestChannelData do use EctoPoly, types: [ sms: TestSmsChannel, email: TestEmailChannel, ] end """ defmacro __using__(opts) do env = __CALLER__ type_field = opts |> Keyword.get(:type_field, :__type__) |> Macro.expand(env) |> Atom.to_string types = opts |> Keyword.fetch!(:types) |> Enum.map(fn {key, value} -> {key |> Macro.expand(env), value |> Macro.expand(env)} end) |> Macro.expand(env) union_type = build_union_type(types) quote do @behaviour Ecto.Type @type_field unquote(type_field) @type t :: unquote(union_type) def type, do: :map EctoPoly.__casters__(unquote(types)) EctoPoly.__dumpers__(unquote(types)) EctoPoly.__loaders__(unquote(types)) def load(data) when is_map(data) do name = data |> Map.get(@type_field) |> String.to_existing_atom fields = data |> Map.delete(@type_field) |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) load(name, fields) end def cast(_), do: :error def dump(_), do: :error end end @doc false defmacro __casters__(types) do types |> Enum.map(&caster/1) end @doc false defmacro __dumpers__(types) do types |> Enum.map(&dumper/1) end @doc false defmacro __loaders__(types) do types |> Enum.map(&loader/1) end defp caster({_, value_type}) do quote do def cast(value = %unquote(value_type){}), do: {:ok, value} end end defp loader({name, value_type}) do loader(is_schema?(value_type), name, value_type) end defp loader(true, name, value_type) do quote do defp load(unquote(name), fields) do result = unquote(value_type) |> Ecto.Schema.__unsafe_load__(fields |> Map.new, &EctoPoly.load_value/2) {:ok, result} end end end defp loader(false, name, value_type) do quote do defp load(unquote(name), fields) do result = unquote(value_type) |> struct!(fields) {:ok, result} end end end defp dumper({name, value_type}) do dumper(is_schema?(value_type), name, value_type) end defp dumper(true, name, value_type) do quote do def dump(value = %unquote(value_type){}) do fields = unquote(value_type).__schema__(:dump) result = value |> EctoPoly.dump_schema(fields) |> Map.put(@type_field, Atom.to_string(unquote(name))) {:ok, result} end end end defp dumper(false, name, value_type) do quote do def dump(value = %unquote(value_type){}) do result = value |> Map.from_struct |> Map.put(@type_field, Atom.to_string(unquote(name))) {:ok, result} end end end defp build_union_type(types) do types |> Enum.reduce(nil, fn (x, acc) -> case acc do nil -> x value -> {:|, [], [value, x]} end end) end defp is_schema?(type) do try do type.__schema__(:query) true rescue _ in UndefinedFunctionError -> false end end @doc false def dump_schema(struct, fields) do fields |> Enum.reduce(%{}, fn {field, {source, type}}, acc -> value = Map.get(struct, field) dumped = dump_value(type, value) case dumped do {:ok, value} -> Map.put(acc, source, value) :error -> raise ArgumentError, "cannot dump `#{inspect value}` as type #{inspect type}" end end) end @doc false def dump_value(type, value) do with {:ok, value} <- Ecto.Type.dump(type, value), {:ok, value} <- transform_dump(type, value) do {:ok, value} else {:error, error} -> {:error, error} :error -> :error end end @doc false def load_value(type, value) do with {:ok, value} <- transform_load(type, value), {:ok, value} <- Ecto.Type.load(type, value) do {:ok, value} else {:error, error} -> {:error, error} :error -> :error end end defp transform_dump(type, value), do: do_transform_dump(Ecto.Type.type(type), value) defp do_transform_dump(_, nil), do: {:ok, nil} defp do_transform_dump(:decimal, value), do: {:ok, Decimal.to_string(value)} defp do_transform_dump(:time, {hour, minute, second, microsecond}) do result = %Time{hour: hour, minute: minute, second: second, microsecond: {microsecond, 6}} |> Time.to_iso8601 {:ok, result} end defp do_transform_dump(:naive_datetime, {{year, month, day}, {hour, minute, second, microsecond}}) do result = %NaiveDateTime{year: year, month: month, day: day, hour: hour, minute: minute, second: second, microsecond: {microsecond, 6}} |> NaiveDateTime.to_iso8601 {:ok, result} end defp do_transform_dump(:utc_datetime, {{year, month, day}, {hour, minute, second, microsecond}}) do result = %DateTime{year: year, month: month, day: day, hour: hour, minute: minute, second: second, microsecond: {microsecond, 6}, std_offset: 0, utc_offset: 0, zone_abbr: "UTC", time_zone: "Etc/UTC"} |> DateTime.to_iso8601 {:ok, result} end defp do_transform_dump({:map, type}, values), do: transform_map(type, values, &transform_dump/2) defp do_transform_dump({:array, type}, values), do: transform_array(type, values, &transform_dump/2) defp do_transform_dump(_, value), do: {:ok, value} defp transform_load(type, value), do: do_transform_load(Ecto.Type.type(type), value) defp do_transform_load(_, nil), do: {:ok, nil} defp do_transform_load(:decimal, value), do: {:ok, Decimal.new(value)} defp do_transform_load(:time, value) do with {:ok, %{ hour: hour, minute: minute, second: second, microsecond: {microsecond, 6} }} = value |> Time.from_iso8601 do {:ok, {hour, minute, second, microsecond}} end end defp do_transform_load(:naive_datetime, value) do with {:ok, %{ year: year, month: month, day: day, hour: hour, minute: minute, second: second, microsecond: {microsecond, 6} }} = value |> NaiveDateTime.from_iso8601 do {:ok, {{year, month, day}, {hour, minute, second, microsecond}}} end end defp do_transform_load(:utc_datetime, value) do with {:ok, %{ year: year, month: month, day: day, hour: hour, minute: minute, second: second, microsecond: {microsecond, 6} }, _} <- value |> DateTime.from_iso8601 do {:ok, {{year, month, day}, {hour, minute, second, microsecond}}} end end defp do_transform_load({:map, type}, values), do: transform_map(type, values, &transform_load/2) defp do_transform_load({:array, type}, values), do: transform_array(type, values, &transform_load/2) defp do_transform_load(_, value), do: {:ok, value} defp transform_map(type, values, fun) do result = values |> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} -> case fun.(type, value) do {:ok, result} -> {:cont, {:ok, [{key, result} | acc]}} {:error, error} -> {:halt, {:error, error}} :error -> {:halt, :error} end end) with {:ok, result} <- result do {:ok, result |> Map.new} end end defp transform_array(type, values, fun) do result = values |> Enum.reduce_while({:ok, []}, fn value, {:ok, acc} -> case fun.(type, value) do {:ok, result} -> {:cont, {:ok, [result | acc]}} {:error, error} -> {:halt, {:error, error}} :error -> {:halt, :error} end end) with {:ok, result} <- result do {:ok, result |> Enum.reverse} end end end