Skip to content

ddl2cpp: register relation members in the generated Description<> - #577

Merged
Yaraslaut merged 2 commits into
masterfrom
fix/556-ddl2cpp-descriptor-relations
Aug 21, 2026
Merged

ddl2cpp: register relation members in the generated Description<>#577
Yaraslaut merged 2 commits into
masterfrom
fix/556-ddl2cpp-descriptor-relations

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Fixes #556.

Root cause

CxxModelPrinter built Description<Record> from the columns only. Relation members were emitted into the struct body but never registered in FieldCount / Members / FieldNames (CxxModelPrinter.cpp:265-297). Since RecordMemberCount prefers the descriptor whenever a specialization exists, EnumerateRecordMembers never visited the relation, ConfigureRelationAutoLoading installed no loader, and the first access threw SqlRequireLoadedError.

The descriptor stands in for reflection, so it must list every non-static data member — RecordColumnCount already documents that it differs from RecordMemberCount exactly by the relation members. Column-only consumers (CreateTable, Create, Update, field-name lists) select by the RecordColumnMember concept rather than by index, so the extra entries cost them nothing. A relation has no SQL column, so its FieldNames slot carries the C++ member name — which is what reflection reports there.

A second defect this unmasked

Fixing the descriptor made chinook stop compiling:

error: static assertion failed due to requirement 'Lookup.candidates != 0':
This relationship requires the referencing record to declare a BelongsTo member...

The generator plans inverse and through relations for foreign keys it never emits a BelongsTo for. A column that is both a primary key and a foreign key is emitted as a plain Field (the isForeignKey && !isPrimaryKey guard at the column emission site), yet HasMany, HasOne, HasManyThrough and HasOneThrough all resolve their other end through exactly that BelongsTo. Chinook's PlaylistTrack is that shape. The bug was invisible only because the truncated descriptor hid those members from ConfigureRelationAutoLoading.

PlanRelations now skips those relations for the same reason it already skips composite foreign keys — no BelongsTo, no relation. That also disqualifies the classic composite-key join table from the through-relation path; a join table carrying a key of its own is unaffected.

⚠️ Reviewer attention. In the reference schema surveyed in docs/ddl2cpp-relation-generation.md, 84 of 159 join-table candidates are the composite-key shape and therefore no longer get a HasManyThrough. Those relations never worked — they did not compile — so this removes generated API that was unusable, not functional. Making the shape work needs BelongsTo to support being a primary key (BelongsTo::IsPrimaryKey is hard-coded false), which is well beyond this issue. Documented as rule 6.

Tests

  • CxxModelPrinterTests.cpp — new case asserting the descriptor covers relation members. Verified failing before the fix (emitted FieldCount = 1, Members without the relation), passing after.
  • DataMapper/DescriptorRelationTests.cpp (new) — a Description<>-specialized record with a HasMany, asserting the relation loads via both QuerySingle and Query<..., loadRelations>, plus that the relation still contributes no column. Verified: temporarily truncating the descriptor to the old generator's shape reproduces the issue exactly (Could not load the data record: HasMany<DescribedAlbum>, Count() == 0).
  • CxxModelRelationTests.cpp — through-relation fixtures moved to a join table with its own key (keeps the cardinality rules covered); new case pins the skipped composite-key shape.
  • Chinook example now traverses album.Track_1, so the ddl2cpp CI leg covers relation loading end to end.

The regenerated entity headers were validated against real ddl2cpp output (run against a SQLite Chinook), not hand-guessed, and every descriptor was checked for member-order/count consistency with its struct body.

Verification

Check Result
clang-debug (clang-tidy + ASan/UBSan) full suite, sqlite3 1405 passed, 1 skipped, 0 failed
chinook builds yes
scripts/check-doc-snippets.py 41 snippets OK
clang-format clean
GCC 15 syntax + static_assert check of all regenerated entity headers clean

Databases: only sqlite3 was run locally — Docker is not available on this machine, so MSSQL/PostgreSQL containers could not be started. The CI matrix covers those.
Compilers: clang-debug locally; gcc-release is disabled on macOS, so the GCC check was limited to a syntax/static_assert pass over the regenerated headers (which is where the GCC-specific risk sits — notably the self-named &Employee::Employee pointer-to-member). CI runs the full GCC legs.

Risk

Low-to-moderate. The descriptor change is additive and mechanical. The relation-planning guard removes generated API surface — but only relations that could not compile. No runtime behaviour changes for records whose descriptors were already complete or absent.

🤖 Generated with Claude Code

@Yaraslaut
Yaraslaut requested a review from a team as a code owner August 19, 2026 07:42
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests Core API labels Aug 19, 2026
@Yaraslaut
Yaraslaut force-pushed the fix/556-ddl2cpp-descriptor-relations branch from 3887f5f to 6fa6d2f Compare August 19, 2026 07:59
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 2 commits August 21, 2026 12:17
CxxModelPrinter built Description<Record> from the columns alone: relation members were emitted
into the struct body but never added to FieldCount / Members / FieldNames. Because
RecordMemberCount prefers the descriptor whenever a specialization exists, EnumerateRecordMembers
never visited the relation, ConfigureRelationAutoLoading installed no loader, and the first access
threw SqlRequireLoadedError.

The descriptor stands in for reflection, so it must list every non-static data member, not only the
ones that map onto a column - RecordColumnCount already documents that it differs from
RecordMemberCount exactly by the relation members. Column-only consumers select by the
RecordColumnMember concept rather than by index, so the extra entries cost them nothing. A relation
has no SQL column, so its FieldNames slot carries the C++ member name, which is what reflection
reports there.

Fixing that exposed a second defect the truncated descriptor had been hiding: the generator plans
inverse and through relations for foreign keys it never emits a BelongsTo for. A column that is
both a primary key and a foreign key is emitted as a plain Field (the isForeignKey &&
!isPrimaryKey guard at the column emission site), yet HasMany, HasOne, HasManyThrough and
HasOneThrough all resolve their other end through exactly that BelongsTo. Chinook's PlaylistTrack
is that shape, and once ConfigureRelationAutoLoading could finally see the relation members the
example stopped compiling on a static_assert. PlanRelations now skips those relations for the same
reason it already skips composite foreign keys - no BelongsTo, no relation - which also disqualifies
the classic composite-key join table from the through-relation path. A join table carrying a key of
its own is unaffected. Supporting the composite-key shape needs BelongsTo to be usable as a primary
key, which it is not today; docs/ddl2cpp-relation-generation.md records that as rule 6 along with
the reference-schema impact.

Regenerates the checked-in Chinook entity headers to match, verified against real ddl2cpp output,
and traverses Album::Track_1 in the example so the ddl2cpp CI leg covers relation loading end to
end. The through-relation fixtures in CxxModelRelationTests move to a join table with its own key,
which keeps the cardinality rules covered, and a new case pins the skipped shape.

Fixes #556

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
…mitted

Applying /code-review findings on this branch.

The new inverse-relation guard covered only one of the reasons PrintTable
declines to emit a BelongsTo -- an FK column that is also a primary key. A
non-declarable self-reference still had a HasMany planned for it. That was
harmless before this PR, because the relation was inert; now that
Description<> lists relation members, ConfigureRelationAutoLoading
instantiates the loader, InverseBelongsToResolver static_asserts, and the
generated header stops compiling. AsJoinTable had the same gap. Two such
shapes are already pinned by existing column-side tests, so this is
reachable from real schemas.

Extract a single IsEmittedAsBelongsTo(table, constraint) predicate that
mirrors the column-emission guard exactly -- composite FK, FK column that is
also a primary key, and self-reference whose target is not a primary key or
not declared before it -- and route both AsJoinTable and EmitInverseRelation
through it. This also removes the two ad-hoc primaryKeys lookups.

Extend rule 6 of the relation-generation doc to list all three cases rather
than only the composite-key join table, and add regression tests for the two
shapes.

Chinook output is unchanged: Employee.ReportsTo -> Employee.EmployeeId is
declarable, so its HasMany is still planned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the fix/556-ddl2cpp-descriptor-relations branch from 1deb995 to b821fbc Compare August 21, 2026 09:24
@Yaraslaut
Yaraslaut merged commit 6e5602a into master Aug 21, 2026
30 checks passed
@Yaraslaut
Yaraslaut deleted the fix/556-ddl2cpp-descriptor-relations branch August 21, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Core API documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ddl2cpp-generated Description<> omits relation members, so relation auto-loading never runs

1 participant