feat(DataMapper): eager relation loading via Query<>().With<>() - #574
Open
Yaraslaut wants to merge 11 commits into
Open
feat(DataMapper): eager relation loading via Query<>().With<>()#574Yaraslaut wants to merge 11 commits into
Yaraslaut wants to merge 11 commits into
Conversation
…is unchanged Prepare() always issued SQLPrepareW, even when the handle already held exactly that query. That shape is everywhere: one relation loader, one INSERT or one QuerySingle repeated per record. Skipping the redundant re-prepare took 1000 identical single-row selects on PostgreSQL from 1053 ms to 190 ms (psqlODBC prepares server-side, one round-trip per call); MS SQL Server and SQLite are unaffected, their drivers already fold it away. Everything else Prepare() does still runs unconditionally - the handle carries column bindings, indicators, post-execute callbacks and possibly parameter-array attributes from the previous execution, and those must be torn down either way. A prepared statement can stop being executable without its SQL text changing: SQL Server compiles against object ids, so dropping and recreating a table between two executions of one handle invalidates the plan (42S02), and PostgreSQL rejects a cached plan whose result type changed (0A000). Both are raised while resolving or planning, before the statement has had any effect, so RetryStalePreparedStatement() re-prepares once and runs again for that SQLSTATE family only - a constraint violation is deliberately not retried. Without that retry the reuse broke ~30 MS SQL Server tests while SQLite and PostgreSQL stayed fully green.
CreateTable<Record>() emitted a FOREIGN KEY constraint for every BelongsTo but no index on the column, and none of the supported engines indexes a foreign key implicitly - only MySQL does. The foreign key is exactly the column every HasMany load filters on, so each relation query was a full table scan, making the per-record loading path quadratic in the row count. Measured on SQLite with 1000 owners x 10 children: the on-demand path drops from 6270 ms to 174 ms (36x), and the batched path from 59.8 ms to 9.0 ms (6.6x). Three DDL-string tests grow the matching CREATE INDEX statements. Note this changes the DDL emitted for existing record types: tables created by an earlier version keep their unindexed foreign keys until recreated or indexed by hand.
Touching a relation on a query result loaded it on demand, one query per record - the N+1 problem, with no way to avoid it short of hand-written WhereIn plus a manual stitch (which is what the shipped Chinook example does). Closes #563. auto albums = dm.Query<Album>() .With<&Album::tracks>() // one extra SELECT ... WHERE album_id IN (...) .With<&Album::artist>() // one extra SELECT ... WHERE id IN (...) .All(); After the result set is materialized, each requested relation is resolved for the whole batch: the keys are collected, the related rows fetched with an IN predicate, and the rows distributed to the records in memory. Applies to All(), First(), First(n) and Range(); the calls chain, one per relation. Measured with 1000 owners x 10 children (fastest of 3, foreign key indexed): HasMany 1001 -> 2 queries: 8.7x (SQLite) 45x (PostgreSQL) 45x (MSSQL) BelongsTo 10001 -> 2 queries: 37x (SQLite) 464x (PostgreSQL) 407x (MSSQL) Both servers were on loopback, so those are lower bounds - the gap grows with network round-trip time while the batched figure stays flat. Design notes: - Each With<>() appends a plain function pointer, not a std::function: the relation is named at compile time, so the loader is one stateless instantiation. The builder's type is unchanged, which keeps the fluent chain and the asynchronous execution mode working as they are. - BelongsTo deduplicates foreign keys before querying, so 10 000 children pointing at 1000 parents fetch 1000 rows, not 10 000. - HasMany groups fetched children by binary search over the sorted owner keys rather than scanning the batch per row, and emplaces an empty list for childless owners so their first access does not query for a result already known to be empty. - Keys are sorted and deduplicated rather than hashed: ordering is all a key column type has to provide, while std::hash is not specialized for all of them. - The IN predicate is chunked through the new virtual SqlQueryFormatter::MaxInPredicateValues() (1000 by default) - per-DBMS dispatch rather than a branch on SqlServerType - so a large batch stays within what each dialect's parser accepts and still costs a constant number of queries per relation. - HasOneThrough, HasManyThrough and CompositeForeignKey keep loading on demand; naming one in With<>() is a compile error, not a silent fallback. - Combined with DataMapperOptions { .loadRelations = false }, any relation not named by With<>() throws SqlRequireLoadedError on access - Django 6.1's FETCH_RAISE, for free. The tests assert the statement count through a counting SqlLogger next to the data, so a silent fallback to the on-demand loaders fails the test rather than passing slowly. Covered: both relation kinds, chaining, NULL foreign keys, childless owners, First(n)/Range(), an empty result set, a 1005-owner batch crossing the chunk boundary, and an unrequested relation still loading lazily.
Documents Query<>().With<>() in the usage guide with the measured query counts and speed-ups, and adds a best-practices entry pairing it with the two things that compound with it: indexed foreign keys, and asserting query counts in tests through a SqlLogger rather than assuming them. src/benchmark/ so far measured compile time only. LightweightRelationBenchmark is its runtime counterpart for relation loading: it reports wall-clock time and the number of statements issued per strategy, over the same data, so a change that reintroduces an N+1 shows up as a count and not merely as a slower number. It runs against any ODBC connection string given as its third argument.
Member
|
i like |
…d depth
One level of eager loading is not enough for a chain of relations. Every record
holds its own copy of its BelongsTo target, so reaching a relation of that copy
runs the copy's own lazy loader - the N+1 moves one level down rather than going
away. Naming the whole path fixes that:
auto tracks = dm.Query<Track>()
.With<&Track::album>() // 1 query for all albums
.With<&Track::album, &Album::artist>() // 1 query for their artists
.All();
Each level is resolved for every record the level above it reached, so a path of
any length costs a constant number of queries per level - three statements in
total here, for any number of tracks. A path may equally run through the "many"
side (With<&Album::tracks, &Track::genre>()): the middle level fans out and the
one below it is still a single query, not one per child.
For a whole object graph rather than named paths, the query takes a depth:
auto tracks = dm.Query<Track, DataMapperOptions { .eagerLoadDepth = 2 }>().All();
which batch-loads every BelongsTo and HasMany reachable within that many levels.
The depth is a compile-time constant, and that is what makes a cyclic relation
graph - a self-referencing record, or A -> B -> A - terminate instead of
instantiating forever. HasOneThrough, HasManyThrough and CompositeForeignKey are
skipped and keep their on-demand behaviour.
Implementation notes:
- The preloaders now address the batch by pointer. Past the first level the
targets are not contiguous: a BelongsTo target lives in the owner's own
unique_ptr and a HasMany list holds shared_ptrs, so only their addresses can
be gathered. The span<Record> entry point adapts a freshly materialized result
set onto that.
- BelongsTo::LoadedRecord() and HasMany::LoadedRecords() report what is loaded
without running the on-demand loader. Walking the path through the ordinary
accessors would have re-created the N+1 inside the walk itself.
- The batched loaders skip a relation that is already in memory, which makes
preloading idempotent: overlapping paths, or a named path next to a depth
walk, fetch each relation once.
Six new tests, all asserting statement counts: a three-level BelongsTo chain, a
path through a HasMany, both depth settings, and the no-double-fetch case.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
The CI style gate runs clang-format over every tracked source; these three files were edited outside the formatter.
- readability-qualified-auto: the two raw string literals are pointers, so `auto const* const`; the two loop bindings are never written through. - bugprone-unchecked-optional-access: REQUIRE() expands to a loop the analyser cannot prove exits, so it does not count as a guard for the dereference that follows. Use the throwing guard the rest of this suite already uses.
Applying /code-review findings on this branch. The Prepare() reuse path skips SQLNumParams(), so the SQLSMALLINT max sentinel that BindInputParameter() writes into m_expectedParameterCount survived into the next execute. dm.Create(rec) followed by dm.CreateAll(vec) prepares byte-identical INSERT text and then threw std::invalid_argument "Invalid number of columns"; every repeated Create() also resized the indicator vector to 32768 entries. Keep the prepared statement's real count in a separate m_preparedParameterCount, set it alongside every SQLNumParams() and restore it on reuse, including the move operations and the stale-statement retry. Regression test added in BatchTests.cpp; verified it fails with the fix reverted. Nested With<>() validated only its first path element against Record. MemberIndexOf<> of any later element was resolved as an index into the previous target, so With<&Track::album, &Track::genre>() silently eager- loaded whatever Album relation happened to sit at that index. Add a static_assert in PreloadRelationPath -- in both the reflection and the non-reflection branch -- requiring each subsequent element to belong to the preceding relation's target. Count()'s new doc claimed a preceding GroupBy is honored. CountImpl never forwards _query.groupBy and SqlQueryFormatter::SelectCount has no GROUP BY parameter; correct the doc. Also drop the dead PreloadRelation(std::span<Record>) overload, whose pointer-gathering loop RunRelationPreloaders already performs, and add the <array> include SqlStatement.cpp needs for RetryStalePreparedStatement. Left alone: With<>() is silently ignored by the projected-field finishers (All<Fields...>, First<Fields...>, Range<Fields...>). A partial SELECT may not fetch the foreign key at all, so this reads as intentional -- but it deserves either a doc note or a diagnostic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed table PreloadBelongsTo's "no targets" arm was unreachable from the suite: the existing optional-relation test gives half its rows a category, so the collected target set was never empty. A batch where every foreign key is NULL now exercises the bail-out that keeps the loader from querying the target table for nothing. The second test covers Prepare() reusing a handle across a DROP/CREATE of the table it reads. It does not close the gap on RetryStalePreparedStatement(): none of the three backends in the matrix rejects the reused handle here -- verified, the "Re-preparing statement" warning never fires, MS SQL Server's Driver 18 defers preparation to execution time -- so the recovery arm needs a fault-injection seam rather than a real driver. The test is worth keeping regardless: recreating a table under a live prepared statement is a real migration shape, and it must return the new table's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch had drifted into conflict with master, which meant GitHub could not build a merge ref and every pull_request-triggered workflow silently stopped running: the last full CI this branch saw was on 79bd7bb, and PR Labeler was the only check reporting since. Two conflicts, both mechanical: - QueryBuilders.hpp: master's doc comment wins. #573 made SelectCount forward GROUP BY, so the branch's note that a preceding GroupBy is "not part of the generated statement" describes behaviour that no longer exists. - DataMapper.hpp: the eager-loading declarations this branch adds sit directly above LoadHasOneThrough's template header, which #580 re-spelled from ThroughRecord to ThroughSpec. Kept both: the new declarations and master's signature. Verified after the merge: clang-debug builds clean, doc snippets match, and the full suite passes on sqlite3 (1426), mssql2022 (1424) and postgres (1425). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A raw string literal decays to a const char*, so the query needs `auto const* const` — the spelling the neighbouring reuse test already uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #563.
Relations could only be loaded one record at a time. Touching
album.tracksover a result set of1000 albums issued 1001 queries, and the only mitigation was a hand-written
WhereInplus a manualstitch — which is exactly what the shipped Chinook example does.
Relations nest, and one level of eager loading is not enough for a chain: each record holds its own
copy of its
BelongsTotarget, so reaching a relation of that copy runs the copy's own lazy loader— the N+1 moves one level down. Naming the whole path resolves each level for everything the level
above reached:
And when a whole object graph is wanted rather than named paths, the query takes a depth:
Two adjacent bottlenecks found while measuring are fixed in the same branch, because the eager path
is 6.6x slower without the first and the remaining per-record path 5.6x slower without the second.
Performance impact
Measured with
LightweightRelationBenchmark(added here), 1000 owners x 10 children,-O2 -DNDEBUG,fastest of 3 runs after a warm-up, statement counts captured through a
SqlLoggerrather thanestimated. Both servers on loopback, so the speed-ups are lower bounds — they grow with network RTT
while the batched figure stays flat.
HasManyon demandHasManywithWith<>()BelongsToon demandBelongsTowithWith<>()Unindexed foreign keys (fixed here): no supported engine indexes a foreign key implicitly, so
every relation query was a full table scan. SQLite, 1000x10 — on-demand path 6270 ms → 174 ms (36x),
batched path 59.8 ms → 9.0 ms (6.6x). At 2000 owners the unindexed on-demand path took 46 seconds.
Redundant re-prepare (fixed here): 1000 identical single-row selects, prepare-each-time vs
prepare-once — PostgreSQL 1053 ms → 190 ms (5.6x), SQLite 21.3 ms → 18.9 ms, MSSQL unchanged. This
also speeds up per-record
Createloops, which re-prepared the same INSERT per row.Not a bottleneck, deliberately left alone: installing the lazy loaders costs nothing measurable
(
loadRelationstrue vs false on a 1000-row query: 0.35 vs 0.32 ms on SQLite, 1.31 vs 1.33 ms onPostgreSQL) — the closures fit libc++'s
std::functioninline buffer.Risk assessment
the SQL text changing. Without a guard this broke ~30 MS SQL Server tests with
42S02 Invalid object name— SQL Server compiles against object ids, so a dropped-and-recreated table invalidatesthe plan — while SQLite and PostgreSQL stayed fully green. Handled by re-preparing once for the
stale-plan SQLSTATE family (
42S02,42P01,0A000,26000,42P05). All of those are raisedwhile resolving or planning, before the statement has had any effect, so re-executing is safe; a
constraint violation is not retried. Residual risk: a multi-statement batch prepared through
Prepare()that fails partway with one of those states would re-run its earlier statements — nosuch call site exists today (migrations go through
ExecuteDirect, which clears the reuse flag).CreateTable<Record>()now emitsCREATE INDEX "<table>_<column>_index"perBelongsTo. Tables created by an earlier version keep unindexed foreign keys until recreated.Three DDL-string tests were updated.
SqlStatementgains a privateboolmember;SqlQueryFormattergains a virtual method(vtable layout change). Source-compatible, not binary-compatible — consistent with the project's
header-heavy design.
fully materialized and its cursor closed, so no second cursor is open on the connection — MARS is
not required.
SqlQueryFormatter::MaxInPredicateValues()(1000),a virtual hook per AGENT.md rather than a branch on
SqlServerType. The chunk-boundary case iscovered by a 1005-owner test.
eagerLoadDepthinstantiates the loader for the whole reachable relation graph, so a deepvalue on a richly connected record costs compile time — the same pressure
src/benchmark/existsto measure. It is opt-in and defaults to
0. The depth being a compile-time constant is also whatterminates a cyclic graph (a self-referencing record, or A → B → A).
paths, or a named path next to a depth walk, fetch each relation once. Two
BelongsToaccessorswere added (
LoadedRecord(),LoadedRecords()) that report what is loaded without running theon-demand loader — walking a path through the ordinary accessors would have re-created the N+1
inside the walk.
Test coverage
17 new test cases.
src/tests/DataMapper/EagerLoadingTests.cppasserts the statement countthrough a counting
SqlLoggeralongside the data, so a silent fallback to the on-demand loadersfails the test instead of passing slowly: both relation kinds, chaining, NULL foreign keys, childless
owners,
First(n)/Range(), an empty result set, a batch crossing the IN-chunk boundary, and anunrequested relation still loading lazily, a three-level
BelongsTochain, a path through aHasMany, botheagerLoadDepthsettings, and a named path not being re-fetched by the depth walk.src/tests/SqlStatementDbTests.cppadds two cases for the reuse path, including a schema changeunderneath a reused prepared statement.
Databases tested
Full suite,
clang-debug(ASan + UBSan + PEDANTIC/-Werror), against isolated databases:sqlite3— 1417 passed, 1 skippedpostgres(Docker 16.4) — 1416 passed, 2 skippedmssql2022(Docker) — 1415 passed, 3 skippedCompilers tested
clang-debug(Apple clang 17) — full suite, all three databases-fsyntax-only). A fullGCC build is not possible on this macOS host for a pre-existing reason (
std::stacktraceisunavailable in Homebrew GCC,
SqlLogger.cpp:291), so thegcc-releaseleg AGENT.md asks for isleft to CI. No MSVC/clang-cl run either — no Windows host available.
LIGHTWEIGHT_BUILD_MODULES=ONnot built: this change adds no namespace-scope entity to a publicheader (
MaxInPredicateValuesis a member function,ForEachChunka function template), so theinternal-linkage rule that configuration enforces does not apply.
Both Docker servers were given dedicated databases for these runs (
test563,LightweightTest563):another suite was running concurrently against the shared ones and both drop and recreate the same
tables, which corrupted an earlier run.
🤖 Generated with Claude Code