From ba21a83b6126de7bcdae98e013141f23644b2867 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Fri, 17 Apr 2026 18:24:56 +0900 Subject: [PATCH 1/3] fix: resolve field/2 ambiguous import in inline embeds do-block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline `embeds_one/embeds_many` with do-blocks previously delegated to `Ecto.Schema.embeds_one/4`, which creates child modules via `Module.create/3` carrying the caller's `__ENV__` — including EctoTypedSchema's `field/2` import. When the child module then `use Ecto.Schema`, both `field/2` imports collide. Fix: create inline child modules ourselves with `use EctoTypedSchema` + `typed_embedded_schema`, then call `Ecto.Schema.__embeds_one__/4` directly. This also gives inline embed modules proper `@type t()` generation, which they previously lacked. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ecto_typed_schema/field_macros.ex | 51 ++++++++++- .../types/embeds_many_test.exs | 65 +++++++++++++- .../types/embeds_one_test.exs | 88 ++++++++++++++++++- 3 files changed, 200 insertions(+), 4 deletions(-) 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..c09c278 100644 --- a/test/ecto_typed_schema/types/embeds_many_test.exs +++ b/test/ecto_typed_schema/types/embeds_many_test.exs @@ -76,10 +76,73 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do typed_schema "users" do embeds_many :addresses, InlineAddress, primary_key: false do - Ecto.Schema.field(:street, :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 + + type_string = + child_types + |> Enum.map(fn {kind, type} -> + "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" + end) + |> Enum.join("\n") + + assert type_string =~ "street: String.t() | nil" + assert type_string =~ "city: 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 Ecto.Schema.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 fetch_types!(Schema) end diff --git a/test/ecto_typed_schema/types/embeds_one_test.exs b/test/ecto_typed_schema/types/embeds_one_test.exs index 3b37adb..8b653f0 100644 --- a/test/ecto_typed_schema/types/embeds_one_test.exs +++ b/test/ecto_typed_schema/types/embeds_one_test.exs @@ -76,16 +76,102 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do typed_schema "users" do embeds_one :address, Address, primary_key: false do - Ecto.Schema.field(:street, :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 + + type_string = + child_types + |> Enum.map(fn {kind, type} -> + "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" + end) + |> Enum.join("\n") + + assert type_string =~ "street: String.t() | nil" + assert type_string =~ "city: 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 Ecto.Schema.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 fetch_types!(Schema) end 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) + + type_string = + child_types + |> Enum.map(fn {kind, type} -> + "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" + end) + |> Enum.join("\n") + + refute type_string =~ "id:" + assert type_string =~ "city: String.t() | nil" + end + end end describe "with null: false" do From 3fe49215c14f3131248d1d9a5b2dc9557125fcd9 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Fri, 17 Apr 2026 18:31:15 +0900 Subject: [PATCH 2/3] test: clean up inline embed tests and add multi-level nested test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Ecto.Schema.field() workaround from expected blocks — plain `field` works inside Ecto.Schema inline embed do-blocks too. Add test for multi-level nested inline embeds (parent → Shipping → Address) to verify the fix works recursively. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../types/embeds_many_test.exs | 6 +-- .../types/embeds_one_test.exs | 53 +++++++++++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/test/ecto_typed_schema/types/embeds_many_test.exs b/test/ecto_typed_schema/types/embeds_many_test.exs index c09c278..b0c8a73 100644 --- a/test/ecto_typed_schema/types/embeds_many_test.exs +++ b/test/ecto_typed_schema/types/embeds_many_test.exs @@ -56,8 +56,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 @@ -121,7 +121,7 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do schema "users" do embeds_many :addresses, InlineAddress, primary_key: false, on_replace: :delete do - Ecto.Schema.field(:city, :string) + field :city, :string end end diff --git a/test/ecto_typed_schema/types/embeds_one_test.exs b/test/ecto_typed_schema/types/embeds_one_test.exs index 8b653f0..34d961b 100644 --- a/test/ecto_typed_schema/types/embeds_one_test.exs +++ b/test/ecto_typed_schema/types/embeds_one_test.exs @@ -56,8 +56,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 @@ -120,7 +120,7 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do schema "users" do embeds_one :address, Address, primary_key: false do - Ecto.Schema.field(:city, :string) + field :city, :string end end @@ -172,6 +172,53 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do assert type_string =~ "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 + + to_string = fn types -> + Enum.map_join(types, "\n", fn {kind, type} -> + "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" + end) + end + + parent_str = to_string.(parent_types) + assert parent_str =~ "shipping: Ecto.Schema.embeds_one(" + assert parent_str =~ "total: Decimal.t() | nil" + + shipping_str = to_string.(shipping_types) + assert shipping_str =~ "carrier: String.t() | nil" + assert shipping_str =~ "Ecto.Schema.embeds_one(" + assert shipping_str =~ "Shipping.Address.t()" + + address_str = to_string.(address_types) + assert address_str =~ "city: String.t() | nil" + assert address_str =~ "zip: String.t() | nil" + end end describe "with null: false" do From 1c980dcde973a2252a0a8af55198f96c87f57d78 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Fri, 17 Apr 2026 18:40:06 +0900 Subject: [PATCH 3/3] test: use exact matching in inline embed type assertions Replace regex `=~` assertions with normalized exact `==` matching. normalize_type/1 strips the module prefix and collapses whitespace so assertions are deterministic regardless of module path length. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../types/embeds_many_test.exs | 26 +++++--- .../types/embeds_one_test.exs | 59 ++++++++----------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/test/ecto_typed_schema/types/embeds_many_test.exs b/test/ecto_typed_schema/types/embeds_many_test.exs index b0c8a73..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 = @@ -101,15 +116,10 @@ defmodule EctoTypedSchema.Types.EmbedsManyTest do child_types = fetch_types!(Schema.InlineAddress) assert [{:type, {:t, _, _}}] = child_types - type_string = - child_types - |> Enum.map(fn {kind, type} -> - "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" - end) - |> Enum.join("\n") + normalized = normalize_type(child_types) - assert type_string =~ "street: String.t() | nil" - assert type_string =~ "city: String.t() | nil" + assert normalized == + "%Schema.InlineAddress{city: String.t() | nil, street: String.t() | nil}" end end diff --git a/test/ecto_typed_schema/types/embeds_one_test.exs b/test/ecto_typed_schema/types/embeds_one_test.exs index 34d961b..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 = @@ -101,15 +116,8 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do child_types = fetch_types!(Schema.Address) assert [{:type, {:t, _, _}}] = child_types - type_string = - child_types - |> Enum.map(fn {kind, type} -> - "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" - end) - |> Enum.join("\n") - - assert type_string =~ "street: String.t() | nil" - assert type_string =~ "city: String.t() | nil" + normalized = normalize_type(child_types) + assert normalized == "%Schema.Address{city: String.t() | nil, street: String.t() | nil}" end end @@ -161,15 +169,8 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do after child_types = fetch_types!(Schema.Address) - type_string = - child_types - |> Enum.map(fn {kind, type} -> - "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" - end) - |> Enum.join("\n") - - refute type_string =~ "id:" - assert type_string =~ "city: String.t() | nil" + normalized = normalize_type(child_types) + assert normalized == "%Schema.Address{city: String.t() | nil}" end end @@ -200,24 +201,14 @@ defmodule EctoTypedSchema.Types.EmbedsOneTest do {parent_types, shipping_types, address_types} = generated_types - to_string = fn types -> - Enum.map_join(types, "\n", fn {kind, type} -> - "@#{kind} #{Macro.to_string(Code.Typespec.type_to_quoted(type))}" - end) - end - - parent_str = to_string.(parent_types) - assert parent_str =~ "shipping: Ecto.Schema.embeds_one(" - assert parent_str =~ "total: Decimal.t() | nil" + 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}" - shipping_str = to_string.(shipping_types) - assert shipping_str =~ "carrier: String.t() | nil" - assert shipping_str =~ "Ecto.Schema.embeds_one(" - assert shipping_str =~ "Shipping.Address.t()" + assert normalize_type(shipping_types) == + "%Schema.Shipping{address: Ecto.Schema.embeds_one(Schema.Shipping.Address.t()) | nil, carrier: String.t() | nil}" - address_str = to_string.(address_types) - assert address_str =~ "city: String.t() | nil" - assert address_str =~ "zip: String.t() | nil" + assert normalize_type(address_types) == + "%Schema.Shipping.Address{city: String.t() | nil, zip: String.t() | nil}" end end