Skip to content

feat: support polymorphic_embed's polymorphic_embeds_one/many (#40) - #64

Merged
bamorim merged 4 commits into
masterfrom
feat-40-polymorphic-embed
Aug 24, 2026
Merged

bamorim merged 4 commits into
masterfrom
feat-40-polymorphic-embed

Conversation

@bamorim

@bamorim bamorim commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

First-party support for polymorphic_embed's polymorphic_embeds_one/2 and polymorphic_embeds_many/2 inside typed_schema/typed_embedded_schema blocks, following the approach discussed in the issue thread (intercept the calls in the syntax sugar).

  • Experimental, opt-in via a compile-time flag (off by default):
    # config/config.exs
    config :typed_ecto_schema, polymorphic_embed: true
    Since the calls are matched purely by name, another library defining same-named macros with different behavior would otherwise break (we'd strip its :null/:enforce options and register a possibly-wrong field type — and the :: override can only fix the type, not the emitted call). With the flag off, these calls go through the pre-existing Macro.expand fallback and behave exactly as before this PR. The flag is read with Application.compile_env/4 so schemas recompile when it changes (requires Elixir 1.14+, the CI floor).
  • No new dependency: the calls are matched purely by name in the AST, the same way SyntaxSugar already matches field/embeds_one/etc. polymorphic_embed is only added as a only: :test dep (latest ~> 5.0) for the integration tests. The lib still compiles (and dialyzes) cleanly in dev/prod without it.
  • Type inference: the typespec is inferred from the types: option AST as a union of the listed modules — types: [sms: SMS, email: Email] infers (SMS.t() | Email.t()) | nil for _one and list(SMS.t() | Email.t()) for _many. Both the name: Module and name: [module: Module, ...] forms are supported. Modules are never resolved (no compile-time deps added); when types: isn't statically known (e.g. a module attribute), it falls back to any().
  • :: override supported on both macros, and :null/:enforce work like they do for field/3.
  • The original call is re-emitted untouched (minus our :null/:enforce/override options, which PolymorphicEmbed.OptionsValidator would reject), so the real macro still runs with its own array?/default handling — no more manual desugaring to field(name, PolymorphicEmbed, ...).

Implementation notes

  • EctoTypeMapper.type_for/4 gets a dedicated head for the polymorphic function names that skips base_type_for (the base type is already built by the syntax sugar) and reuses the existing list-wrapping and nullability rules (_one nullable by default, _many always a list).
  • Only the options add_field actually reads (:null, :enforce, :default, override) are forwarded to it, so the types: aliases are never evaluated by our generated code — keeping the compile-time-dependency avoidance that polymorphic_embed itself implements via expand_alias.
  • Drive-by: removed a field_is_nullable?/3 clause that was already unreachable (fully shadowed by the @schema_many_function_name clause — has_many/many_to_many appear in both lists) and now triggers a redundant-clause compiler warning on recent Elixir versions. Behavior is unchanged (existing tests cover it).

Tests (coverage: 100%)

  • polymorphic_embeds_one with literal types → union type, nullable by default
  • polymorphic_embeds_many with literal types → list(union), never nil
  • :: override on both → override wins
  • module: keyword form of types: (and an atom-literal module form)
  • :enforce/:null options (also proves the option-dropping works, since polymorphic_embed rejects unknown options)
  • the real macros still run (fields registered on the Ecto schema, _many defaults to [])
  • types: from a module attribute → falls back to any(); unresolvable types: entries → any()
  • non-literal opts are left completely untouched
  • flag disabled (default): a same-named macro from another library (test stub) is expanded and typed exactly as before this PR, and the real polymorphic_embed macros fall back to the old PolymorphicEmbed.t() | nil behavior
  • a schema importing PolymorphicEmbed but not using it is unaffected; the rest of the suite covers schemas without polymorphic_embed entirely

mix test (40 passing), mix credo --strict and mix dialyzer are clean in both dev and test envs.

Documented (as experimental) in the README and the TypedEctoSchema moduledoc.

Closes #40

🤖 Generated with Claude Code

bamorim and others added 3 commits August 24, 2026 10:54
Intercept polymorphic_embeds_one/2 and polymorphic_embeds_many/2 calls in
the syntax sugar, matching purely on the call name so polymorphic_embed
never becomes a dependency of this library. The typespec is inferred as
the union of the modules listed in the :types option (supporting both the
`name: Module` and `name: [module: Module, ...]` forms), falling back to
any() when the types are not statically resolvable. The original call is
re-emitted untouched (minus our :null/:enforce/:: options) so the real
macro still runs.

The ::/:null/:enforce options work the same way they do for field/3.

Also removes a field_is_nullable?/3 clause that was already unreachable
(fully shadowed by the @schema_many_function_name clause) and now triggers
a redundant-clause warning on recent Elixir versions.

polymorphic_embed is added as a test-only dependency for the integration
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the new polymorphic-embed edge paths (atom module in :types,
unresolvable module/entry fallbacks to any(), non-literal opts left
untouched) plus previously uncovered branches in the files touched by
the previous commit: field/3 with opts and a :: override, composite
{:array, _}/{:map, _} types, evaluated-atom and unknown Ecto types, and
the non-atom field name error in TypeBuilder.add_field/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The integration matches polymorphic_embeds_one/2 and
polymorphic_embeds_many/2 purely by name, so another library defining
same-named macros with different behavior would break: we would strip
its :null/:enforce options and register a possibly-wrong field type,
and the :: override can only fix the type, not the emitted call.

Make the integration opt-in via compile-time config, off by default:

    config :typed_ecto_schema, polymorphic_embed: true

The flag is read with Application.compile_env/4 so schemas recompile
when it changes (requires Elixir 1.14+, the CI floor). With the flag
disabled the calls go through the pre-existing Macro.expand fallback,
behaving exactly as before the integration existed — locked in by tests
against both a same-named stub macro from another library and the real
polymorphic_embed macros. The integration is documented as experimental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bamorim
bamorim force-pushed the feat-40-polymorphic-embed branch from 2de5e59 to daf87a7 Compare August 24, 2026 09:55
@bamorim bamorim mentioned this pull request Aug 24, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bamorim
bamorim merged commit 8d27ddd into master Aug 24, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Third party Module type

1 participant