Skip to content

fix: reject non-Ref foreign key cycles at metamodel generation time - #440

Merged
zantvoort merged 3 commits into
mainfrom
fix/non-ref-fk-cycle-diagnostic
Aug 10, 2026
Merged

fix: reject non-Ref foreign key cycles at metamodel generation time#440
zantvoort merged 3 commits into
mainfrom
fix/non-ref-fk-cycle-diagnostic

Conversation

@zantvoort

@zantvoort zantvoort commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #412.

Problem

Two entities referencing each other with non-Ref @FK fields — or an entity referencing itself — compiled with no diagnostic, but the generated metamodels could never be used. Their constructors build record-typed children eagerly, so the metamodels of a cycle construct each other until the stack overflows: the first metamodel use after a clean build died with an ExceptionInInitializerError wrapping a StackOverflowError. The existing guards in the processors only prevented generating a file twice; the generated code itself still recursed. Verified with a reproduction test against the unmodified processor before applying the fix: the mutual Owner/Pet pair compiled cleanly and constructing the generated metamodel overflowed the stack.

Fix

Both processors (APT and KSP) run a memoized depth-first check over non-Ref record fields before generation and reject a cycle with an error naming its members and the fix:

Cycle of non-Ref foreign keys: Owner -> Pet -> Owner. A foreign key cycle must cross
a Ref boundary to be loadable. Mark one of the foreign keys as Ref (for example
Ref<Owner>) to break the cycle.

This mirrors the rule the engine already states at template level for self-references (TemplatePreparation): a foreign key cycle must cross a Ref boundary to be loadable. A cycle of inline records gets its own diagnostic, since an inline record embeds its columns and a cycle cannot be modeled at all. The error is attached to the field that closes the cycle and each cycle is reported once, not doubled by the nullable-chain variants.

The runtime record validation (RecordValidation.validateRecordGraph), which catches such a cycle at first model use when the processors are not attached, reports the same message: it renders the cycle members from the type the cycle re-enters and names the Ref fix, instead of the bare "Cyclic dependency detected". Its traversal already skipped Ref edges, so enforcement is unchanged; only the message is aligned.

Tests

Mirrored across both processor suites:

  • the mutual non-Ref @FK cycle is rejected, with the cycle named and (Java side) the diagnostic asserted to print exactly once,
  • a self-referencing non-Ref @FK is rejected (Employee -> Employee),
  • a cycle that crosses a Ref boundary still generates as before,
  • a three-entity chain closed by a Ref at depth generates as before, including the reference metamodel at the boundary and the navigation metamodels beyond it.

Full 25-module reactor build is green with the check active and produces zero cycle diagnostics, so no existing model is affected. The storm-core validation and metamodel test classes (155 tests) are green after the runtime message alignment.

Two entities referencing each other with non-Ref @fk fields, or an entity
referencing itself, generated metamodels whose constructors build each
other's record-typed children eagerly: the first metamodel use died at
class initialization with an ExceptionInInitializerError wrapping a
StackOverflowError.

Both processors now run a memoized depth-first check over non-Ref record
fields before generation and report an error naming the cycle and the fix,
mirroring the rule the engine states at template level for self-references:
a foreign key cycle must cross a Ref boundary to be loadable. A cycle of
inline records gets its own diagnostic, since an inline record embeds its
columns and a cycle cannot be modeled at all. Each cycle is reported once,
not doubled by the nullable-chain variants.

Covered by mirrored tests in both processor suites: the mutual cycle and
the self-reference are rejected with the cycle named, and a cycle that
crosses a Ref boundary still generates as before.
@zantvoort zantvoort added this to the 1.14.0 milestone Aug 10, 2026
A three-entity chain whose cycle closes through a Ref generates as usual
in both processors, including the reference metamodel at the boundary and
the navigation metamodels beyond it: the cycle check skips Ref edges, so
only cycles that cannot be loaded are rejected.
… cycle

The record graph validation named the cycle but not the remedy, while the
self-reference check in TemplatePreparation and the processor diagnostics
both state that a foreign key cycle must cross a Ref boundary. The cycle
message now matches the processors: it renders the cycle members from the
type the cycle re-enters and names the fix, with the inline-record variant
for cycles of non-Data records.
@zantvoort
zantvoort merged commit 32117d6 into main Aug 10, 2026
7 checks passed
@zantvoort
zantvoort deleted the fix/non-ref-fk-cycle-diagnostic branch August 10, 2026 19:05
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...va/st/orm/core/template/impl/RecordValidation.java 50.00% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

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.

Non-Ref FK cycles between entities generate a metamodel that fails at class initialization

1 participant