Skip to content

fix: avoid compile-time deps from schema function options (#38) - #67

Merged
bamorim merged 1 commit into
masterfrom
fix-38-compile-deps
Aug 25, 2026
Merged

bamorim merged 1 commit into
masterfrom
fix-38-compile-deps

Conversation

@bamorim

@bamorim bamorim commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Problem

many_to_many(:reviews, Review, join_through: Book) inside typed_schema created a compile-time dependency on Book, while the identical schema with plain Ecto.Schema does not (#38, reproduced in axelson/typed_ecto_schema_repro). This causes recompilation cascades in user projects.

Root cause

The syntax sugar for schema functions injected the full options list, unescaped, into the generated TypeBuilder.add_field/5 call:

unquote(TypeBuilder).add_field(__MODULE__, ..., unquote(Macro.escape(type)), unquote(opts))

The type was escaped (PR #18), but opts was not — so every option value was evaluated in the module body at compile time, and a module alias in an option like join_through: resolved into a compile-time dependency. (Ecto itself avoids this by expanding such aliases under a fake function env.)

Fix

Forward only the options add_field/5 actually reads — :null, :enforce, :default, :values, :__typed_ecto_type__, and the belongs_to-specific :define_field/:foreign_key/:type — mirroring how the polymorphic embeds support already handles this. Notes:

  • The forwarded values keep their original AST (not escaped), so values: @attr still resolves to the real list in the module body — the additional_types feature and enum type inference keep working.
  • The AST-variable opts path (issue 0.4.2 breaks usage in defmacro #52) does the same Keyword.take at runtime.
  • The full original options still go to the re-emitted Ecto call untouched (minus the enhanced keys, as before).
  • The custom-Ecto.Type case from the issue (field(:country, Country)) is untouched: that compile-time dependency is inherent to Ecto (it exists with plain Ecto.Schema too).

Regression test (#26)

Adds test/typed_ecto_schema/compile_deps_test.exs: it compiles a schema from a string with a compiler tracer attached and records every module referenced at compile time (env.function == nil, which is what makes mix xref draw a compile edge). It asserts the join_through: module is never referenced at compile time, while a custom Ecto.Type used with field is — proving both that the tracer setup detects deps and that the Ecto-inherent case stays untouched. The test fails on master and passes with this fix.

Verification with mix xref

Throwaway project using this branch as a path dep, with two schemas — TypedUser (typed_schema) and PlainUser (plain Ecto.Schema), both declaring many_to_many(:reviews, XrefRepro.Review, join_through: XrefRepro.Book) in separate files.

Before (master):

$ mix xref graph --label compile
lib/xref_repro/typed_user.ex
└── lib/xref_repro/book.ex (compile)

After (this branch):

$ mix xref graph --label compile
(no output — no compile edges, matching plain Ecto)

mix test (66 passed), mix credo --strict, and mix dialyzer all pass.

Closes #38
Closes #26

🤖 Generated with Claude Code

The syntax sugar for schema functions injected the full options list,
unescaped, into the generated `TypeBuilder.add_field/5` call. Every
option value was therefore evaluated in the module body at compile
time, so a module alias in an option like `join_through:` became a
compile-time dependency — one plain Ecto.Schema does not have.

Forward only the options add_field actually reads (`:null`,
`:enforce`, `:default`, `:values`, `:__typed_ecto_type__`, and the
belongs_to-specific `:define_field`/`:foreign_key`/`:type`), mirroring
the polymorphic embeds path. The forwarded values keep their original
AST, so `values: @attr` still resolves in the module body, and the full
original options still go to the re-emitted Ecto call untouched.

Also adds the compile-time dependency regression test requested in #26,
using a compiler tracer: the `join_through:` module must not be
referenced at compile time, while a custom `Ecto.Type` used with
`field` must (that dependency is inherent to Ecto).

Closes #38
Closes #26

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bamorim
bamorim merged commit 940fe54 into master Aug 25, 2026
15 checks passed
bamorim added a commit that referenced this pull request Aug 25, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bamorim added a commit that referenced this pull request Aug 25, 2026
* release: prepare 0.5.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: note additional_types + polymorphic_embed interaction in changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: warn that list fields' named types represent a single element

A plural field name like :roles or :channels gets a named type that is
the element union, not the list — call that out explicitly since the
pluralized name reads as if it were the list type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add #67 and #68 to the 0.5.0 changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Fields still cause compile-time dependencies Regression test for compile time dependencies

1 participant