ddl2cpp: register relation members in the generated Description<> - #577
Merged
Conversation
Yaraslaut
force-pushed
the
fix/556-ddl2cpp-descriptor-relations
branch
from
August 19, 2026 07:59
3887f5f to
6fa6d2f
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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
force-pushed
the
fix/556-ddl2cpp-descriptor-relations
branch
from
August 21, 2026 09:24
1deb995 to
b821fbc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #556.
Root cause
CxxModelPrinterbuiltDescription<Record>from the columns only. Relation members were emitted into the struct body but never registered inFieldCount/Members/FieldNames(CxxModelPrinter.cpp:265-297). SinceRecordMemberCountprefers the descriptor whenever a specialization exists,EnumerateRecordMembersnever visited the relation,ConfigureRelationAutoLoadinginstalled no loader, and the first access threwSqlRequireLoadedError.The descriptor stands in for reflection, so it must list every non-static data member —
RecordColumnCountalready documents that it differs fromRecordMemberCountexactly by the relation members. Column-only consumers (CreateTable,Create,Update, field-name lists) select by theRecordColumnMemberconcept rather than by index, so the extra entries cost them nothing. A relation has no SQL column, so itsFieldNamesslot carries the C++ member name — which is what reflection reports there.A second defect this unmasked
Fixing the descriptor made
chinookstop compiling:The generator plans inverse and through relations for foreign keys it never emits a
BelongsTofor. A column that is both a primary key and a foreign key is emitted as a plainField(theisForeignKey && !isPrimaryKeyguard at the column emission site), yetHasMany,HasOne,HasManyThroughandHasOneThroughall resolve their other end through exactly thatBelongsTo. Chinook'sPlaylistTrackis that shape. The bug was invisible only because the truncated descriptor hid those members fromConfigureRelationAutoLoading.PlanRelationsnow 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.Tests
CxxModelPrinterTests.cpp— new case asserting the descriptor covers relation members. Verified failing before the fix (emittedFieldCount = 1,Memberswithout the relation), passing after.DataMapper/DescriptorRelationTests.cpp(new) — aDescription<>-specialized record with aHasMany, asserting the relation loads via bothQuerySingleandQuery<..., 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.album.Track_1, so the ddl2cpp CI leg covers relation loading end to end.The regenerated entity headers were validated against real
ddl2cppoutput (run against a SQLite Chinook), not hand-guessed, and every descriptor was checked for member-order/count consistency with its struct body.Verification
clang-debug(clang-tidy + ASan/UBSan) full suite, sqlite3chinookbuildsscripts/check-doc-snippets.pyclang-formatstatic_assertcheck of all regenerated entity headersDatabases: only
sqlite3was run locally — Docker is not available on this machine, so MSSQL/PostgreSQL containers could not be started. The CI matrix covers those.Compilers:
clang-debuglocally;gcc-releaseis disabled on macOS, so the GCC check was limited to a syntax/static_assertpass over the regenerated headers (which is where the GCC-specific risk sits — notably the self-named&Employee::Employeepointer-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