diff --git a/lib/ecto_typed_schema/field_macros.ex b/lib/ecto_typed_schema/field_macros.ex index 84bca0f..4391fdc 100644 --- a/lib/ecto_typed_schema/field_macros.ex +++ b/lib/ecto_typed_schema/field_macros.ex @@ -114,13 +114,22 @@ defmodule EctoTypedSchema.FieldMacros do @spec embeds_one(atom(), module(), keyword(), keyword()) :: Macro.t() defmacro embeds_one(name, schema, opts, do: block) do + schema = expand_nested_module_alias(schema, __CALLER__) {typed, opts} = Keyword.pop(opts, :typed, []) typed = sanitize_typed_opts(typed) quote location: :keep do @ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))} - Ecto.Schema.embeds_one(unquote(name), unquote(schema), unquote(opts), do: unquote(block)) + {schema, opts} = + EctoTypedSchema.FieldMacros.create_inline_module( + __ENV__, + unquote(schema), + unquote(opts), + unquote(Macro.escape(block)) + ) + + Ecto.Schema.__embeds_one__(__MODULE__, unquote(name), schema, opts) end end @@ -138,13 +147,22 @@ defmodule EctoTypedSchema.FieldMacros do @spec embeds_many(atom(), module(), keyword(), keyword()) :: Macro.t() defmacro embeds_many(name, schema, opts, do: block) do + schema = expand_nested_module_alias(schema, __CALLER__) {typed, opts} = Keyword.pop(opts, :typed, []) typed = sanitize_typed_opts(typed) quote location: :keep do @ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))} - Ecto.Schema.embeds_many(unquote(name), unquote(schema), unquote(opts), do: unquote(block)) + {schema, opts} = + EctoTypedSchema.FieldMacros.create_inline_module( + __ENV__, + unquote(schema), + unquote(opts), + unquote(Macro.escape(block)) + ) + + Ecto.Schema.__embeds_many__(__MODULE__, unquote(name), schema, opts) end end @@ -231,4 +249,33 @@ defmodule EctoTypedSchema.FieldMacros do defp maybe_put_through(typed, opts) do Keyword.put(typed, :through, Keyword.get(opts, :through)) end + + @doc false + @spec create_inline_module(Macro.Env.t(), module(), keyword(), Macro.t()) :: + {module(), keyword()} + def create_inline_module(env, module, opts, block) do + {pk, opts} = Keyword.pop(opts, :primary_key, {:id, :binary_id, autogenerate: true}) + + body = + quote do + use EctoTypedSchema + + @primary_key unquote(Macro.escape(pk)) + + typed_embedded_schema do + unquote(block) + end + end + + Module.create(module, body, env) + {module, opts} + end + + defp expand_nested_module_alias({:__aliases__, _, [Elixir | _] = alias}, _env), + do: Module.concat(alias) + + defp expand_nested_module_alias({:__aliases__, _, [h | t]}, env) when is_atom(h), + do: Module.concat([env.module, h | t]) + + defp expand_nested_module_alias(other, _env), do: other end diff --git a/test/ecto_typed_schema/types/embeds_many_test.exs b/test/ecto_typed_schema/types/embeds_many_test.exs index 25760d5..56e34e0 100644 --- a/test/ecto_typed_schema/types/embeds_many_test.exs +++ b/test/ecto_typed_schema/types/embeds_many_test.exs @@ -14,6 +14,21 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do end end + defp normalize_type(types) do + prefix = inspect(__MODULE__) <> "." + + types + |> Enum.map(fn {_kind, type} -> Macro.to_string(Code.Typespec.type_to_quoted(type)) end) + |> Enum.map_join(" ", fn s -> + s + |> String.replace(~r/^t\(\) :: /, "") + |> String.replace(prefix, "") + |> String.replace(~r/\s+/, " ") + |> String.replace("{ ", "{") + |> String.replace(" }", "}") + end) + end + describe "basic embeds_many (non-nullable, defaults to [])" do test "generates list type without nil", ctx do expected_types = @@ -56,8 +71,8 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do schema "users" do embeds_many :addresses, InlineAddress, primary_key: false do - Ecto.Schema.field(:street, :string) - Ecto.Schema.field(:city, :string) + field :street, :string + field :city, :string end end @@ -76,8 +91,66 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do typed_schema "users" do embeds_many :addresses, InlineAddress, primary_key: false do - Ecto.Schema.field(:street, :string) - Ecto.Schema.field(:city, :string) + field :street, :string + field :city, :string + end + end + after + fetch_types!(Schema) + end + + assert_type(expected_types, generated_types) + end + + test "inline child module gets @type t()", ctx do + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "users" do + embeds_many :addresses, InlineAddress, primary_key: false do + field :street, :string + field :city, :string + end + end + after + child_types = fetch_types!(Schema.InlineAddress) + assert [{:type, {:t, _, _}}] = child_types + + normalized = normalize_type(child_types) + + assert normalized == + "%Schema.InlineAddress{city: String.t() | nil, street: String.t() | nil}" + end + end + + test "inline embeds_many with on_replace: :delete — Ecto opt forwarded, type unaffected", + ctx do + expected_types = + with_tmpmodule Schema, ctx do + use Ecto.Schema + + schema "users" do + embeds_many :addresses, InlineAddress, primary_key: false, on_replace: :delete do + field :city, :string + end + end + + @type t() :: %__MODULE__{ + __meta__: Ecto.Schema.Metadata.t(__MODULE__), + id: integer(), + addresses: Ecto.Schema.embeds_many(__MODULE__.InlineAddress.t()) + } + after + fetch_types!(Schema) + end + + generated_types = + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "users" do + embeds_many :addresses, InlineAddress, primary_key: false, on_replace: :delete do + field :city, :string end end after diff --git a/test/ecto_typed_schema/types/embeds_one_test.exs b/test/ecto_typed_schema/types/embeds_one_test.exs index 3b37adb..df55afd 100644 --- a/test/ecto_typed_schema/types/embeds_one_test.exs +++ b/test/ecto_typed_schema/types/embeds_one_test.exs @@ -14,6 +14,21 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do end end + defp normalize_type(types) do + prefix = inspect(__MODULE__) <> "." + + types + |> Enum.map(fn {_kind, type} -> Macro.to_string(Code.Typespec.type_to_quoted(type)) end) + |> Enum.map_join(" ", fn s -> + s + |> String.replace(~r/^t\(\) :: /, "") + |> String.replace(prefix, "") + |> String.replace(~r/\s+/, " ") + |> String.replace("{ ", "{") + |> String.replace(" }", "}") + end) + end + describe "basic embeds_one (nullable by default)" do test "generates embed type with nil", ctx do expected_types = @@ -56,8 +71,8 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do schema "users" do embeds_one :address, Address, primary_key: false do - Ecto.Schema.field(:street, :string) - Ecto.Schema.field(:city, :string) + field :street, :string + field :city, :string end end @@ -76,8 +91,63 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do typed_schema "users" do embeds_one :address, Address, primary_key: false do - Ecto.Schema.field(:street, :string) - Ecto.Schema.field(:city, :string) + field :street, :string + field :city, :string + end + end + after + fetch_types!(Schema) + end + + assert_type(expected_types, generated_types) + end + + test "inline child module gets @type t()", ctx do + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "users" do + embeds_one :address, Address, primary_key: false do + field :street, :string + field :city, :string + end + end + after + child_types = fetch_types!(Schema.Address) + assert [{:type, {:t, _, _}}] = child_types + + normalized = normalize_type(child_types) + assert normalized == "%Schema.Address{city: String.t() | nil, street: String.t() | nil}" + end + end + + test "inline embeds_one with typed: [null: false] makes parent type non-nullable", ctx do + expected_types = + with_tmpmodule Schema, ctx do + use Ecto.Schema + + schema "users" do + embeds_one :address, Address, primary_key: false do + field :city, :string + end + end + + @type t() :: %__MODULE__{ + __meta__: Ecto.Schema.Metadata.t(__MODULE__), + id: integer(), + address: Ecto.Schema.embeds_one(__MODULE__.Address.t()) + } + after + fetch_types!(Schema) + end + + generated_types = + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "users" do + embeds_one :address, Address, primary_key: false, typed: [null: false] do + field :city, :string end end after @@ -86,6 +156,60 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do assert_type(expected_types, generated_types) end + + test "inline embeds_one with primary_key: false — child has no :id in type", ctx do + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "users" do + embeds_one :address, Address, primary_key: false do + field :city, :string + end + end + after + child_types = fetch_types!(Schema.Address) + + normalized = normalize_type(child_types) + assert normalized == "%Schema.Address{city: String.t() | nil}" + end + end + + test "multi-level nested inline embeds compile and generate types", ctx do + generated_types = + with_tmpmodule Schema, ctx do + use EctoTypedSchema + + typed_schema "orders" do + field :total, :decimal + + embeds_one :shipping, Shipping, primary_key: false do + field :carrier, :string + + embeds_one :address, Address, primary_key: false do + field :city, :string + field :zip, :string + end + end + end + after + parent_types = fetch_types!(Schema) + shipping_types = fetch_types!(Schema.Shipping) + address_types = fetch_types!(Schema.Shipping.Address) + + {parent_types, shipping_types, address_types} + end + + {parent_types, shipping_types, address_types} = generated_types + + assert normalize_type(parent_types) == + "%Schema{__meta__: Ecto.Schema.Metadata.t(Schema), id: integer(), shipping: Ecto.Schema.embeds_one(Schema.Shipping.t()) | nil, total: Decimal.t() | nil}" + + assert normalize_type(shipping_types) == + "%Schema.Shipping{address: Ecto.Schema.embeds_one(Schema.Shipping.Address.t()) | nil, carrier: String.t() | nil}" + + assert normalize_type(address_types) == + "%Schema.Shipping.Address{city: String.t() | nil, zip: String.t() | nil}" + end end describe "with null: false" do