Skip to content

Commit 444cdb4

Browse files
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)
2 parents dac8c87 + 6a6a8ae commit 444cdb4

17 files changed

Lines changed: 1936 additions & 30 deletions

docs/best-practices.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ single response.
106106
This will help to reduce the response time and the load on the server, and improve the performance of your
107107
application.
108108
109+
### Load relations for a whole result set, not per record
110+
111+
Touching a relation on each record of a result set issues one query per record — the N+1 problem. Name
112+
the relation on the query instead, and it is resolved for the entire batch in a constant number of
113+
queries:
114+
115+
```cpp
116+
auto albums = dm.Query<Album>().With<&Album::tracks>().All(); // 2 queries, not 1 + N
117+
```
118+
119+
A nested relation needs the whole path named — `.With<&Track::album, &Album::artist>()` — because
120+
each record holds its own copy of the target, so one level of eager loading leaves the level below it
121+
loading per record. `DataMapperOptions { .eagerLoadDepth = N }` loads everything reachable instead,
122+
at the cost of fetching more than you asked for.
123+
124+
See [Eager loading of relations](usage.md). Two things compound with it:
125+
126+
- **Index your foreign keys.** `CreateTable<Record>()` emits an index for every `BelongsTo` column,
127+
because no supported engine indexes a foreign key implicitly. Tables created by hand, or by an older
128+
version of Lightweight, need that index added — without it every relation query is a full table scan.
129+
- **Prove the absence of N+1 in tests.** A `SqlLogger` subclass counting `OnPrepare`/`OnExecuteDirect`
130+
turns "this endpoint issues two queries" into an assertion instead of an assumption.
131+
109132
### Let block-prefetch cut network round-trips
110133

111134
Per-row fetch loops issue one `SQLFetch` (one network round-trip) per row. Lightweight transparently

docs/usage.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,87 @@ void BulkInsert(DataMapper& dm, std::vector<Person> const& people)
231231
> records (treat them as write-only inputs), and `UpdateAll` writes a uniform set of columns for every
232232
> row rather than only the per-record modified ones. The range must be contiguous.
233233
234+
### Eager loading of relations (`With<>()`)
235+
236+
Accessing a relation on a query result loads it on demand — one query per record. Over a result set of
237+
N records that is the N+1 problem: reading `album.tracks` for 1000 albums issues 1001 queries.
238+
`With<&Record::relation>()` instead resolves the relation for the whole result set once it has been
239+
materialized, using `WHERE <key> IN (...)`:
240+
241+
```cpp
242+
// Two queries in total, whatever the number of albums: one for the albums, one for all their tracks.
243+
auto albums = dm.Query<Album>()
244+
.With<&Album::tracks>() // HasMany
245+
.With<&Album::artist>() // BelongsTo
246+
.All();
247+
248+
for (auto& album: albums)
249+
for (auto const& track: album.tracks.All()) // already loaded, no query
250+
std::println("{} - {}", album.title, track->title);
251+
```
252+
253+
- Supported for `BelongsTo` and `HasMany`. `HasOneThrough`, `HasManyThrough` and `CompositeForeignKey`
254+
still load on demand; naming one of them in `With<>()` is a compile error rather than a silent
255+
fallback.
256+
- Naming several relations forms a **path**, which is what a nested relation needs — see below.
257+
- Calls chain, one per relation to load. It applies to `All()`, `First()`, `First(n)` and `Range()`.
258+
- The `IN` predicate is chunked (see `SqlQueryFormatter::MaxInPredicateValues`, 1000 by default), so a
259+
large batch costs one query per chunk — a constant number of queries per relation, never one per
260+
record.
261+
- A `BelongsTo` whose foreign key is `NULL`, and an owner with no children, are handled without an
262+
extra query: the childless owner's relation is marked loaded-and-empty rather than left to query for
263+
a result already known.
264+
- Relations that were *not* named keep their on-demand behaviour. Combining `With<>()` with
265+
`DataMapperOptions { .loadRelations = false }` therefore turns any unrequested relation access into a
266+
`SqlRequireLoadedError` instead of a silent query — useful to prove a code path issues no N+1.
267+
268+
#### Nested relations
269+
270+
Eager-loading one level is not enough for a chain. Every record holds its *own copy* of its
271+
`BelongsTo` target, so reaching a relation of that copy runs the copy's own lazy loader — the N+1
272+
simply moves one level down. Name the whole path instead:
273+
274+
```cpp
275+
auto tracks = dm.Query<Track>()
276+
.With<&Track::album>() // 1 query for all albums
277+
.With<&Track::album, &Album::artist>() // 1 query for all those albums' artists
278+
.All();
279+
280+
for (auto& track: tracks)
281+
std::println("{} - {}", track.album.Record().title,
282+
track.album.Record().artist.Record().name); // no queries here
283+
```
284+
285+
Three queries in total, for any number of tracks. Each level is resolved for every record reached by
286+
the level above it, at once. A path may also run through the "many" side
287+
(`.With<&Album::tracks, &Track::genre>()`): the middle level fans out, and the level below it is
288+
still one query rather than one per child.
289+
290+
Already-loaded relations are skipped, so overlapping paths (`.With<&A::b>()` next to
291+
`.With<&A::b, &B::c>()`) do not fetch `b` twice.
292+
293+
#### Loading everything reachable
294+
295+
When a whole object graph is wanted rather than named paths, set a depth on the query instead:
296+
297+
```cpp
298+
// Tracks, their albums and categories, and those albums' artists - a constant number of queries.
299+
auto tracks = dm.Query<Track, DataMapperOptions { .eagerLoadDepth = 2 }>().All();
300+
```
301+
302+
`eagerLoadDepth` batch-loads *every* `BelongsTo` and `HasMany` reachable within that many levels.
303+
Prefer `With<>()` when only part of the graph is needed: the depth walk fetches more rows, and
304+
instantiates the loader for the whole reachable relation graph, which costs compile time. The depth
305+
is what bounds both — and what lets a cyclic graph (a self-referencing record, or `A → B → A`)
306+
terminate, since the recursion is cut at a compile-time constant.
307+
308+
Measured on 1000 owners with 10 children each, comparing the on-demand path with `With<>()`:
309+
310+
| relation | queries before | queries after | SQLite3 | PostgreSQL | MS SQL Server |
311+
|---|---:|---:|---:|---:|---:|
312+
| `HasMany` | 1001 | 2 | 8.7x | 45x | 45x |
313+
| `BelongsTo` | 10001 | 2 | 37x | 464x | 407x |
314+
234315
## Simple row retrieval via structs
235316
236317
When only read access is needed, you can use a simple `struct` to represent the row,

src/Lightweight/DataMapper/BelongsTo.hpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,24 @@ class BelongsTo
316316
return static_cast<bool>(_referencedFieldValue);
317317
}
318318

319+
/// @brief Returns the already-loaded referenced record, or `nullptr` when none is loaded.
320+
///
321+
/// Unlike `Record()`, this never runs the on-demand loader: it reports what is present right
322+
/// now. That is what lets the batched relation loading walk one level deeper (`With<A, B>()`)
323+
/// without turning the walk itself into the N+1 it exists to remove.
324+
///
325+
/// @return Pointer to the loaded record, or `nullptr` if the relation is unloaded or NULL.
326+
[[nodiscard]] LIGHTWEIGHT_FORCE_INLINE constexpr ReferencedRecord* LoadedRecord() noexcept
327+
{
328+
return _record.get();
329+
}
330+
331+
/// @copydoc LoadedRecord()
332+
[[nodiscard]] LIGHTWEIGHT_FORCE_INLINE constexpr ReferencedRecord const* LoadedRecord() const noexcept
333+
{
334+
return _record.get();
335+
}
336+
319337
/// Emplaces a record into the relationship. This will mark the relationship as loaded.
320338
[[nodiscard]] LIGHTWEIGHT_FORCE_INLINE constexpr ReferencedRecord& EmplaceRecord()
321339
{

0 commit comments

Comments
 (0)