Skip to content

feat(DataMapper): eager relation loading via Query<>().With<>() - #574

Open
Yaraslaut wants to merge 6 commits into
masterfrom
feat/563-eager-loading
Open

feat(DataMapper): eager relation loading via Query<>().With<>()#574
Yaraslaut wants to merge 6 commits into
masterfrom
feat/563-eager-loading

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #563.

Relations could only be loaded one record at a time. Touching album.tracks over a result set of
1000 albums issued 1001 queries, and the only mitigation was a hand-written WhereIn plus a manual
stitch — which is exactly what the shipped Chinook example does.

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();

Relations nest, and one level of eager loading is not enough for a chain: each 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. Naming the whole path resolves each level for everything the level
above reached:

auto tracks = dm.Query<Track>()
                .With<&Track::album>()                   // 1 query for all albums
                .With<&Track::album, &Album::artist>()   // 1 query for those albums' artists
                .All();                                  // 3 statements, any number of tracks

And when a whole object graph is wanted rather than named paths, the query takes a depth:

auto tracks = dm.Query<Track, DataMapperOptions { .eagerLoadDepth = 2 }>().All();

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 SqlLogger rather than
estimated. Both servers on loopback, so the speed-ups are lower bounds — they grow with network RTT
while the batched figure stays flat.

scenario queries SQLite3 PostgreSQL 16.4 MSSQL 2022
HasMany on demand 1001 152 ms 618 ms 493 ms
HasMany with With<>() 2 17.5 ms 13.7 ms 10.9 ms
8.7x 45x 45x
BelongsTo on demand 10001 976 ms 5898 ms 3934 ms
BelongsTo with With<>() 2 26.0 ms 12.7 ms 9.7 ms
37x 464x 407x

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 Create loops, which re-prepared the same INSERT per row.

Not a bottleneck, deliberately left alone: installing the lazy loaders costs nothing measurable
(loadRelations true vs false on a 1000-row query: 0.35 vs 0.32 ms on SQLite, 1.31 vs 1.33 ms on
PostgreSQL) — the closures fit libc++'s std::function inline buffer.

Risk assessment

  • Prepared-statement reuse is the risky change. A cached plan can stop being executable without
    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 invalidates
    the 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 raised
    while 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 — no
    such call site exists today (migrations go through ExecuteDirect, which clears the reuse flag).
  • DDL change. CreateTable<Record>() now emits CREATE INDEX "<table>_<column>_index" per
    BelongsTo. Tables created by an earlier version keep unindexed foreign keys until recreated.
    Three DDL-string tests were updated.
  • ABI. SqlStatement gains a private bool member; SqlQueryFormatter gains a virtual method
    (vtable layout change). Source-compatible, not binary-compatible — consistent with the project's
    header-heavy design.
  • Threading / ODBC versions. Untouched. The batched loaders run after the outer result set is
    fully materialized and its cursor closed, so no second cursor is open on the connection — MARS is
    not required.
  • Per-DBMS. IN-list size is bounded through SqlQueryFormatter::MaxInPredicateValues() (1000),
    a virtual hook per AGENT.md rather than a branch on SqlServerType. The chunk-boundary case is
    covered by a 1005-owner test.
  • eagerLoadDepth instantiates the loader for the whole reachable relation graph, so a deep
    value on a richly connected record costs compile time — the same pressure src/benchmark/ exists
    to measure. It is opt-in and defaults to 0. The depth being a compile-time constant is also what
    terminates a cyclic graph (a self-referencing record, or A → B → A).
  • Preloading is idempotent. The batched loaders skip a relation already in memory, so overlapping
    paths, or a named path next to a depth walk, fetch each relation once. Two BelongsTo accessors
    were added (LoadedRecord(), LoadedRecords()) that report what is loaded without running the
    on-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.cpp asserts the statement count
through a counting SqlLogger alongside the data, so a silent fallback to the on-demand loaders
fails 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 an
unrequested relation still loading lazily, a three-level BelongsTo chain, a path through a
HasMany, both eagerLoadDepth settings, and a named path not being re-fetched by the depth walk.
src/tests/SqlStatementDbTests.cpp adds two cases for the reuse path, including a schema change
underneath a reused prepared statement.

Databases tested

Full suite, clang-debug (ASan + UBSan + PEDANTIC/-Werror), against isolated databases:

  • sqlite3 — 1417 passed, 1 skipped
  • postgres (Docker 16.4) — 1416 passed, 2 skipped
  • mssql2022 (Docker) — 1415 passed, 3 skipped

Compilers tested

  • clang-debug (Apple clang 17) — full suite, all three databases
  • GCC 15 — the new headers and both new translation units compile clean (-fsyntax-only). A full
    GCC build is not possible on this macOS host for a pre-existing reason (std::stacktrace is
    unavailable in Homebrew GCC, SqlLogger.cpp:291), so the gcc-release leg AGENT.md asks for is
    left to CI. No MSVC/clang-cl run either — no Windows host available.
  • LIGHTWEIGHT_BUILD_MODULES=ON not built: this change adds no namespace-scope entity to a public
    header (MaxInPredicateValues is a member function, ForEachChunk a function template), so the
    internal-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

…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.
@Yaraslaut
Yaraslaut requested a review from a team as a code owner August 18, 2026 15:56
@github-actions github-actions Bot added documentation Improvements or additions to documentation Data Mapper Query Builder tests benchmark Core API Query Formatter SQL dialect implementations labels Aug 18, 2026
@christianparpart

Copy link
Copy Markdown
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

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.26425% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Lightweight/DataMapper/DataMapper.hpp 96.05% 6 Missing ⚠️
src/Lightweight/SqlStatement.cpp 78.57% 6 Missing ⚠️
src/Lightweight/SqlStatement.hpp 75.00% 1 Missing ⚠️

📢 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark Core API Data Mapper documentation Improvements or additions to documentation Query Builder Query Formatter SQL dialect implementations tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No eager-loading API: relation access across N records issues N queries

2 participants