Skip to content
Merged
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
51 changes: 49 additions & 2 deletions lib/ecto_typed_schema/field_macros.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
81 changes: 77 additions & 4 deletions test/ecto_typed_schema/types/embeds_many_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
132 changes: 128 additions & 4 deletions test/ecto_typed_schema/types/embeds_one_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading