diff --git a/lib/field.ex b/lib/field.ex index ac7c2ca..c576c44 100644 --- a/lib/field.ex +++ b/lib/field.ex @@ -24,6 +24,8 @@ defmodule Strukt.Field do :field, :embeds_one, :embeds_many, + :polymorphic_embeds_one, + :polymorphic_embeds_many, :timestamps ] @@ -41,6 +43,20 @@ defmodule Strukt.Field do for {type, meta, args} <- fields, do: parse(type, meta, args) end + defp parse(type, meta, [name, opts]) + when type in [:polymorphic_embeds_one, :polymorphic_embeds_many] do + {validations, options} = Keyword.split(opts, @validation_opts) + + %__MODULE__{ + name: name, + type: type, + meta: meta, + value_type: PolymorphicEmbed, + options: options, + validations: validations + } + end + defp parse(type, meta, [name, value_type]) when type in @supported_field_types, do: %__MODULE__{name: name, type: type, meta: meta, value_type: value_type} @@ -93,6 +109,16 @@ defmodule Strukt.Field do def to_ast(%__MODULE__{type: :timestamps, meta: meta, options: options}), do: {:timestamps, meta, options} + def to_ast(%__MODULE__{ + type: type, + name: name, + meta: meta, + options: options, + block: nil + }) + when type in [:polymorphic_embeds_one, :polymorphic_embeds_many], + do: {type, meta, [name, options]} + def to_ast(%__MODULE__{ type: type, name: name, diff --git a/lib/params.ex b/lib/params.ex index 382632c..1766087 100644 --- a/lib/params.ex +++ b/lib/params.ex @@ -78,11 +78,90 @@ defmodule Strukt.Params do cardinality: cardinality )} + {:parameterized, {PolymorphicEmbed, _opts}} -> + {field, + transform_polymorphic(module, field, value, get_struct_field_value(struct, field))} + + {:array, {:parameterized, {PolymorphicEmbed, _opts}}} -> + {field, + transform_polymorphic(module, field, value, get_struct_field_value(struct, field), + cardinality: :many + )} + _type -> {field, value} end end + defp transform_polymorphic(module, field, params, struct, opts \\ []) + + # Keep nil as-is so required validation and PolymorphicEmbed's own handling can run later. + defp transform_polymorphic(_module, _field, nil, _struct, _opts), do: nil + + # Struct params have already been cast by the caller, so leave them untouched. + defp transform_polymorphic(_module, _field, %_{} = params, _struct, _opts), do: params + + # For polymorphic_embeds_many, transform each map using the matching current embed by index. + # Non-map structs in the list are preserved by the clause above. + defp transform_polymorphic(module, field, params, struct, cardinality: :many) + when is_list(params) do + current = List.wrap(struct) + + params + |> Enum.with_index() + |> Enum.map(fn + {%_{} = param, _index} -> + param + + {param, index} -> + transform_polymorphic(module, field, param, Enum.at(current, index)) + end) + end + + # Map params need the selected polymorphic module before source-field mapping can be applied. + # If the type cannot be inferred, keep the params unchanged and let cast_polymorphic_embed/3 + # produce the configured error/raise/nilify behavior. + defp transform_polymorphic(module, field, params, struct, _opts) when is_map(params) do + case PolymorphicEmbed.get_polymorphic_module(module, field, params) do + nil -> + params + + embedded_module -> + type_field_name = polymorphic_type_field_name(module, field) + type = get_params_field_value(params, type_field_name, nil) + struct = polymorphic_struct_for_module(struct, embedded_module) + + embedded_module + |> transform(params, struct) + |> maybe_put_polymorphic_type(type_field_name, type) + end + rescue + _ -> params + end + + # Leave invalid shapes alone so the eventual cast can report the type error. + defp transform_polymorphic(_module, _field, params, _struct, _opts), do: params + + defp polymorphic_struct_for_module(struct, module) when is_struct(struct, module), do: struct + defp polymorphic_struct_for_module(_struct, _module), do: nil + + # The type marker must be restored after source-field mapping, otherwise PolymorphicEmbed + # cannot infer which embedded schema to cast. + defp polymorphic_type_field_name(module, field) do + case module.__schema__(:type, field) do + {:parameterized, {PolymorphicEmbed, opts}} -> + opts.type_field_name + + {:array, {:parameterized, {PolymorphicEmbed, opts}}} -> + opts.type_field_name + end + end + + defp maybe_put_polymorphic_type(params, _type_field_name, nil), do: params + + defp maybe_put_polymorphic_type(params, type_field_name, type), + do: Map.put(params, type_field_name, type) + defp get_params_field_value(nil, _field, _struct), do: nil defp get_params_field_value(params, field, struct) when is_list(params) do diff --git a/lib/strukt.ex b/lib/strukt.ex index f2ee863..25f0781 100644 --- a/lib/strukt.ex +++ b/lib/strukt.ex @@ -54,6 +54,8 @@ defmodule Strukt do :field, :embeds_one, :embeds_many, + :polymorphic_embeds_one, + :polymorphic_embeds_many, :belongs_to, :has_many, :has_one, @@ -283,7 +285,12 @@ defmodule Strukt do {node, meta, elements} -> kvs = Keyword.merge( - [type: t, value_type: f.value_type, default: f.options[:default]], + [ + type: t, + value_type: f.value_type, + default: f.options[:default], + types: f.options[:types] + ], f.validations ) @@ -297,6 +304,12 @@ defmodule Strukt do # Get a list of embeds valid for `cast_embed/3` cast_embed_fields = for %{type: t} = f <- fields, t in [:embeds_one, :embeds_many], do: f.name + # Get a list of polymorphic embeds valid for `PolymorphicEmbed.cast_polymorphic_embed/3` + cast_polymorphic_embed_fields = + for %{type: t} = f <- fields, t in [:polymorphic_embeds_one, :polymorphic_embeds_many] do + f.name + end + # Expand fields back to their final AST form fields_ast = fields @@ -321,6 +334,7 @@ defmodule Strukt do use Ecto.Schema import Ecto.Changeset, except: [change: 2] + import PolymorphicEmbed, only: [polymorphic_embeds_one: 2, polymorphic_embeds_many: 2] @behaviour unquote(__MODULE__) @before_compile unquote(__MODULE__) @@ -358,6 +372,7 @@ defmodule Strukt do @schema_name Macro.underscore(__MODULE__) @validated_fields unquote(validated_fields) @cast_embed_fields unquote(Macro.escape(cast_embed_fields)) + @cast_polymorphic_embed_fields unquote(Macro.escape(cast_polymorphic_embed_fields)) # Ensure primary key can be cast, if applicable case Module.get_attribute(__MODULE__, :primary_key) do @@ -444,12 +459,13 @@ defmodule Strukt do def change(entity_or_changeset, params) do case entity_or_changeset do %Ecto.Changeset{} = cs -> - cs - |> Ecto.Changeset.change(params) - |> validate() + entity = Ecto.Changeset.apply_changes(cs) + formed_params = Strukt.Params.transform(__MODULE__, params, entity) + changeset(cs, formed_params, cs.action) %__MODULE__{} = entity -> - changeset(entity, params, :update) + formed_params = Strukt.Params.transform(__MODULE__, params, entity) + changeset(entity, formed_params, :update) end end @@ -493,7 +509,7 @@ defmodule Strukt do caller: __MODULE__, info: @validated_fields, fields: @cast_fields, - embeds: @cast_embed_fields + embeds: @cast_embed_fields ++ @cast_polymorphic_embed_fields }) Code.eval_quoted(typespec_ast, [], __ENV__) @@ -535,6 +551,28 @@ defmodule Strukt do cast(entity, params, @cast_fields) |> Map.put(:action, action) |> __cast_embeds__(@cast_embed_fields) + |> __cast_polymorphic_embeds__(@cast_polymorphic_embed_fields) + |> validate() + end + + def changeset(%Ecto.Changeset{data: %__MODULE__{}} = changeset, params, action) + when action in [:insert, :update, :delete, nil] do + params = + case params do + %__MODULE__{} -> + Map.from_struct(params) + + m when is_map(m) -> + m + + other -> + Enum.into(other, %{}) + end + + cast(changeset, params, @cast_fields) + |> Map.put(:action, action) + |> __cast_embeds__(@cast_embed_fields) + |> __cast_polymorphic_embeds__(@cast_polymorphic_embed_fields) |> validate() end @@ -574,6 +612,33 @@ defmodule Strukt do end end + defp __cast_polymorphic_embeds__(changeset, []), do: changeset + + if length(@cast_polymorphic_embed_fields) > 0 do + defp __cast_polymorphic_embeds__(%Ecto.Changeset{params: params} = changeset, [ + field | fields + ]) do + f = to_string(field) + + changeset = + case Map.get(params, f) do + nil -> + changeset + + %_{} = entity -> + Ecto.Changeset.put_change(changeset, field, entity) + + [%_{} | _] = entities -> + Ecto.Changeset.put_change(changeset, field, entities) + + _other -> + PolymorphicEmbed.cast_polymorphic_embed(changeset, field) + end + + __cast_polymorphic_embeds__(changeset, fields) + end + end + @doc """ Applies the changes in the changset if the changeset is valid, returning the updated data. The action must be one of `:insert`, `:update`, or `:delete` and @@ -592,7 +657,7 @@ defmodule Strukt do @doc "Deserialize this type from a JSON string or iodata" @spec from_json(binary | iodata) :: {:ok, t} | {:error, reason :: term} def from_json(input) do - with {:ok, map} <- Jason.decode(input, keys: :atoms!, strings: :copy) do + with {:ok, map} <- Jason.decode(input, strings: :copy) do {:ok, Ecto.embedded_load(__MODULE__, map, :json)} end end diff --git a/lib/typespec.ex b/lib/typespec.ex index 7e75394..4292185 100644 --- a/lib/typespec.ex +++ b/lib/typespec.ex @@ -82,6 +82,19 @@ defmodule Strukt.Typespec do {name, %{type: :embeds_many, value_type: type}} -> {name, List.wrap(compose_call(type, :t, []))} + + {name, %{type: :polymorphic_embeds_one, types: types} = meta} -> + required? = Map.get(meta, :required) == true + type_name = polymorphic_type_name(types) + + if required? do + {name, type_name} + else + {name, nilable(type_name)} + end + + {name, %{type: :polymorphic_embeds_many, types: types}} -> + {name, List.wrap(polymorphic_type_name(types))} end) # Join all fields together @@ -104,6 +117,24 @@ defmodule Strukt.Typespec do defp nilable(type_name), do: {:|, [], [type_name, nil]} + defp polymorphic_type_name(types) when is_list(types) do + types + |> Enum.map(fn + {_type, type_opts} when is_list(type_opts) -> + Keyword.fetch!(type_opts, :module) + + {_type, module} -> + module + end) + |> Enum.map(&compose_call(&1, :t, [])) + |> union() + end + + defp polymorphic_type_name(_types), do: primitive(:any) + + defp union([type]), do: type + defp union([type | types]), do: {:|, [], [type, union(types)]} + defp type_to_type_name(:id), do: primitive(:non_neg_integer) defp type_to_type_name(:binary_id), do: primitive(:binary) defp type_to_type_name(:integer), do: primitive(:integer) diff --git a/mix.exs b/mix.exs index 5cead50..bc1592c 100644 --- a/mix.exs +++ b/mix.exs @@ -52,6 +52,7 @@ defmodule Strukt.MixProject do defp deps do [ {:ecto, "~> 3.12"}, + {:polymorphic_embed, "~> 5.0"}, {:jason, "> 0.0.0", optional: true}, {:ex_doc, "> 0.0.0", only: [:docs], runtime: false} ] diff --git a/mix.lock b/mix.lock index 0f7839d..37d13f4 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "attrs": {:hex, :attrs, "0.6.0", "25d738b47829f964a786ef73897d2550b66f3e7d1d7c49a83bc8fd81c71bed93", [:mix], [], "hexpm", "9c30ac15255c2ba8399263db55ba32c2f4e5ec267b654ce23df99168b405c82e"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, @@ -8,5 +9,6 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "polymorphic_embed": {:hex, :polymorphic_embed, "5.0.6", "58d8c5fe3df3c0cf1e3b8990318cf93489360fbaea555d9520f0b50577ea7e5b", [:mix], [{:attrs, "~> 0.6", [hex: :attrs, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "99c7a7aed3d4b00bd08ce91c9b880deaa47b649904fca8b745f3a50b9b3b3835"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, } diff --git a/test/strukt_test.exs b/test/strukt_test.exs index 36e790b..5cbe8e8 100644 --- a/test/strukt_test.exs +++ b/test/strukt_test.exs @@ -282,6 +282,193 @@ defmodule Strukt.Test do refute is_nil(uuid) end + test "can cast polymorphic embeds from params" do + params = %{ + "title" => "appointment", + "channel" => %{"__type__" => "email", "emailAddress" => "person@example.com"}, + "fallback_channels" => [ + %{"__type__" => "sms", "number" => "+15555550100"}, + %{"__type__" => "email", "emailAddress" => "backup@example.com"} + ] + } + + assert {:ok, + %Fixtures.PolymorphicReminder{ + title: "appointment", + channel: %Fixtures.PolymorphicEmail{address: "person@example.com"}, + fallback_channels: [ + %Fixtures.PolymorphicSMS{number: "+15555550100"}, + %Fixtures.PolymorphicEmail{address: "backup@example.com"} + ] + }} = Fixtures.PolymorphicReminder.new(params) + end + + test "can cast polymorphic embeds from structs" do + reminder = %Fixtures.PolymorphicReminder{} + channel = %Fixtures.PolymorphicSMS{number: "+15555550101"} + + assert {:ok, %Fixtures.PolymorphicReminder{channel: ^channel}} = + Fixtures.PolymorphicReminder.change(reminder, channel: channel) + |> Fixtures.PolymorphicReminder.from_changeset() + end + + test "can cast polymorphic embeds from changesets" do + assert {:ok, reminder} = + Fixtures.PolymorphicReminder.new(%{ + channel: %{__type__: :email, emailAddress: "old@example.com"} + }) + + changeset = Fixtures.PolymorphicReminder.changeset(reminder) + + assert {:ok, %Fixtures.PolymorphicReminder{channel: %Fixtures.PolymorphicSMS{number: "+1"}}} = + Fixtures.PolymorphicReminder.change(changeset, %{ + "channel" => %{"__type__" => "sms", "number" => "+1"} + }) + |> Fixtures.PolymorphicReminder.from_changeset() + + assert {:ok, + %Fixtures.PolymorphicReminder{ + channel: %Fixtures.PolymorphicEmail{address: "new@example.com"} + }} = + Fixtures.PolymorphicReminder.change(changeset, %{ + channel: %{__type__: :email, emailAddress: "new@example.com"} + }) + |> Fixtures.PolymorphicReminder.from_changeset() + end + + test "can change polymorphic embed types without reusing old embedded data" do + assert {:ok, reminder} = + Fixtures.PolymorphicReminder.new(%{ + channel: %{__type__: :email, emailAddress: "old@example.com"} + }) + + assert {:ok, %Fixtures.PolymorphicReminder{channel: %Fixtures.PolymorphicSMS{number: "+1"}}} = + Fixtures.PolymorphicReminder.change(reminder, %{ + channel: %{__type__: :sms, number: "+1"} + }) + |> Fixtures.PolymorphicReminder.from_changeset() + end + + test "can cast polymorphic embeds with changeset public APIs" do + changeset = + Fixtures.PolymorphicReminder.changeset(%Fixtures.PolymorphicReminder{}, %{ + "channel" => %{"__type__" => "email", "address" => "person@example.com"} + }) + + assert {:ok, + %Fixtures.PolymorphicReminder{ + channel: %Fixtures.PolymorphicEmail{address: "person@example.com"} + }} = Fixtures.PolymorphicReminder.from_changeset(changeset) + + changeset = + Fixtures.PolymorphicReminder.changeset( + %Fixtures.PolymorphicReminder{}, + %{channel: %{__type__: :sms, number: "+15555550102"}}, + :insert + ) + + assert changeset.action == :insert + + assert {:ok, + %Fixtures.PolymorphicReminder{ + channel: %Fixtures.PolymorphicSMS{number: "+15555550102"} + }} = Fixtures.PolymorphicReminder.from_changeset(changeset) + end + + test "can normalize nil and empty polymorphic embeds many params" do + for fallback_channels <- [nil, []] do + assert {:ok, + %Fixtures.PolymorphicReminder{ + channel: %Fixtures.PolymorphicSMS{number: "+1"}, + fallback_channels: [] + }} = + Fixtures.PolymorphicReminder.new(%{ + channel: %{__type__: :sms, number: "+1"}, + fallback_channels: fallback_channels + }) + end + end + + test "returns changeset errors for unknown polymorphic embed types" do + assert {:error, changeset} = + Fixtures.PolymorphicReminder.new(channel: %{__type__: :push, token: "abc"}) + + assert %{channel: ["is invalid"]} = changeset_errors(changeset) + + assert {:error, changeset} = + Fixtures.PolymorphicReminder.new(%{ + channel: %{__type__: :sms, number: "+1"}, + fallback_channels: [%{__type__: :push, token: "abc"}] + }) + + assert %{fallback_channels: ["is invalid"]} = changeset_errors(changeset) + end + + test "can deserialize polymorphic embeds from json" do + json = + Jason.encode!(%{ + title: "appointment", + channel: %{__type__: :email, emailAddress: "person@example.com"}, + fallback_channels: [ + %{__type__: :sms, number: "+15555550100"}, + %{__type__: :email, emailAddress: "backup@example.com"} + ] + }) + + assert {:ok, + %Fixtures.PolymorphicReminder{ + title: "appointment", + channel: %Fixtures.PolymorphicEmail{address: "person@example.com"}, + fallback_channels: [ + %Fixtures.PolymorphicSMS{number: "+15555550100"}, + %Fixtures.PolymorphicEmail{address: "backup@example.com"} + ] + }} = Fixtures.PolymorphicReminder.from_json(json) + end + + test "can round-trip polymorphic embeds through json" do + assert {:ok, reminder} = + Fixtures.PolymorphicReminder.new(%{ + channel: %{__type__: :email, emailAddress: "person@example.com"}, + fallback_channels: [%{__type__: :sms, number: "+15555550100"}] + }) + + assert {:ok, json} = Jason.encode(reminder) + assert {:ok, ^reminder} = Fixtures.PolymorphicReminder.from_json(json) + end + + test "raises when json contains an unknown polymorphic embed type" do + json = + Jason.encode!(%{ + channel: %{__type__: :push, token: "abc"} + }) + + assert_raise RuntimeError, ~r/could not infer polymorphic embed/, fn -> + Fixtures.PolymorphicReminder.from_json(json) + end + end + + test "can validate required polymorphic embeds" do + assert {:error, changeset} = Fixtures.PolymorphicReminder.new() + assert %{channel: ["channel must be set"]} = changeset_errors(changeset) + end + + test "can validate polymorphic embedded schemas" do + assert {:error, %Ecto.Changeset{changes: %{channel: channel_changeset}}} = + Fixtures.PolymorphicReminder.new(channel: %{__type__: :email}) + + assert %{address: ["can't be blank"]} = changeset_errors(channel_changeset) + end + + test "can validate polymorphic embeds through validate public api" do + changeset = + %Fixtures.PolymorphicReminder{} + |> Ecto.Changeset.change() + |> Fixtures.PolymorphicReminder.validate() + + assert %{channel: ["channel must be set"]} = changeset_errors(changeset) + end + test "parse custom fields with empty params" do assert {:ok, %Strukt.Test.Fixtures.CustomFieldsWithEmbeddedSchema{ diff --git a/test/support/defstruct_fixtures.ex b/test/support/defstruct_fixtures.ex index 3c441db..263b6bc 100644 --- a/test/support/defstruct_fixtures.ex +++ b/test/support/defstruct_fixtures.ex @@ -294,6 +294,55 @@ defmodule Strukt.Test.Fixtures do end end + defmodule PolymorphicEmail do + use Strukt + + @primary_key false + defstruct do + field(:address, :string, source: :emailAddress, required: true) + end + end + + defmodule PolymorphicSMS do + use Strukt + + @primary_key false + defstruct do + field(:number, :string, required: true) + end + end + + defmodule PolymorphicReminder do + use Strukt + + alias Strukt.Test.Fixtures.PolymorphicEmail + alias Strukt.Test.Fixtures.PolymorphicSMS + + @derives [Jason.Encoder] + defstruct do + field(:title, :string) + + polymorphic_embeds_one(:channel, + types: [ + email: PolymorphicEmail, + sms: PolymorphicSMS + ], + on_type_not_found: :changeset_error, + on_replace: :update, + required: [message: "channel must be set"] + ) + + polymorphic_embeds_many(:fallback_channels, + types: [ + email: PolymorphicEmail, + sms: PolymorphicSMS + ], + on_type_not_found: :changeset_error, + on_replace: :delete + ) + end + end + defstruct Inline do @moduledoc "This module represents the simplest possible use of defstruct/2, i.e. inline definition of a struct and its module"