Commit 444cdb4
authored
feat(DataMapper): eager relation loading via Query<>().With<>() (#574)
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.
```cpp
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:
```cpp
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:
```cpp
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](https://claude.com/claude-code)17 files changed
Lines changed: 1936 additions & 30 deletions
File tree
- docs
- src
- Lightweight
- DataMapper
- SqlQuery
- benchmark
- tests
- DataMapper
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
109 | 132 | | |
110 | 133 | | |
111 | 134 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
231 | 231 | | |
232 | 232 | | |
233 | 233 | | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
234 | 315 | | |
235 | 316 | | |
236 | 317 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
316 | 316 | | |
317 | 317 | | |
318 | 318 | | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
319 | 337 | | |
320 | 338 | | |
321 | 339 | | |
| |||
0 commit comments