Skip to content

feat(test): assert how many SQL statements a spec's call sends - #4357

Merged
bpamiri merged 2 commits into
developfrom
peter/query-assertions
Oct 5, 2026
Merged

bpamiri merged 2 commits into
developfrom
peter/query-assertions

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A spec couldn't assert that a call ran no query, or a given number of queries. That made N+1 loads, missing caches and unexpected writes hard to test without reaching into framework internals.

wheels.WheelsTest gains these functions, available in every framework and app spec:

Function Fails unless
assertQueries(count, callback) callback sends exactly count SQL statements
assertNoQueries(callback) it sends none
assertQueriesMatch(pattern, callback, count) at least one statement (or exactly count) matches the regex, case-insensitive
assertNoQueriesMatch(pattern, callback) no statement matches
recordQueries(callback) never fails: returns [{sql, dataSource}]
var post = assertQueries(1, () => model("Post").findByKey(1));
assertNoQueries(() => post.title);
assertNoQueriesMatch("comments", () => model("Post").findAll(include = "author"));

Design

  • Where statements are counted. Base.$executeQuery() is the one place every model statement runs: finders, count(), save() / create() / update() / delete(), updateAll() / deleteAll(), and each adapter's $performQuery(). While a recorder is active, each statement is added to it.
    • Not counted, and documented: a query-cache hit, which sends no SQL; raw queryExecute() / cfquery, including framework features built on it (advisory locks, the migration lock, the job store and SQL Server probes); transaction control; and test-client requests, which run in their own request.
    • The framework's own raw probes (SQL Server compatibility level, bind types) bypass $executeQuery(), so they don't make counts noisy.
  • Bound values become ?. Each recorded statement is its SQL with ? for every bound value (an IN list shows (?)), so a failure message, which lists the statements, doesn't put bound data in a CI log. A value written into the SQL itself, as with parameterize=false, appears as written. The spec asserts that a bound where value doesn't appear in the message.
  • Idle cost: one key check per statement when nothing is recording.
  • Nesting. Recorders sit on a request-scoped stack, and each counts its own window; an outer recorder also sees the inner statements.
  • dataSource filter. Every function takes an optional dataSource that counts only statements sent there, for multi-datasource and tenant apps.
  • Cleanup (invariant 22). The recorder comes off in a catch-free try/finally, through $stopQueryRecorder(), with no loop in the finally (invariant 12). A throwing or aborting callback can't leave a recorder behind, and TryCatchFinallyGuardSpec passes.
  • Return value. assertQueries() and the other assertions return the callback's result, so a call can stay inline.

Docs

  • The v4-2-0 model-tests guide: "Counting queries".
  • .ai/testing.md (the consumer copy and the wheels new template copy).
  • changelog.d/query-assertions.added.md.

Tests

wheelstest/QueryAssertionsSpec covers:

  • statements recorded with ? for each bound value;
  • the callback's result returned;
  • a failure listing the statements without the bound value;
  • pattern matching, including an INSERT and a failing assertNoQueriesMatch;
  • nested recorders counting their own windows;
  • a throwing callback leaving no recorder;
  • the dataSource filter;
  • nothing recorded while idle.

RED on develop (only the spec added): 7 of the 8 checks fail or error. The idle check is the control.

GREEN locally, full core suite on SQLite, 0 fail / 0 error:

Engine Pass
Lucee 7 6820
BoxLang 6820
Adobe 2025 6814, plus 1 fail (the INSERT check; see the note)
Adobe 2023 6814, plus 1 fail (the INSERT check; see the note)
RustCFML 6795

All four CFML engines discovered 542 bundles. The Lucee 7, BoxLang and RustCFML numbers are after the INSERT-check fix. The Adobe runs were before it: that check was their only failure, it failed the same way on every engine, and the fix is spec-only. Adobe wasn't rerun locally afterwards, so the matrix covers it. After rebasing onto develop (with #4349), the Lucee 7 wheelstest, model and security specs and the guides build pass.

Note on the first full run. The INSERT check originally created a tag. view/formsSpec.cfc registers presence validations for name and virtual on the shared tag class and didn't remove them, so in a full run the create failed validation (Virtual property can't be empty) and sent no INSERT. The check now uses author and asserts the create succeeded. The leak predates this PR and is fixed separately in #4365. (An earlier version of this note blamed callbacksSpec; that was wrong.)

Docs-only follow-up (9659550, after matrix 37339528267). The wording now says bound values become ? and a parameterize=false value appears as written. The not-counted list now names the framework features built on queryExecute(). No .cfc / .cfm changed.

🤖 Generated with Claude Code

A spec couldn't assert that a call ran no query, or N queries, so N+1 loads,
missing caches and unexpected writes were hard to test.

WheelsTest gains assertQueries(count, callback), assertNoQueries(callback),
assertQueriesMatch(pattern, callback, count), assertNoQueriesMatch(pattern,
callback) and recordQueries(callback). Base.$executeQuery(), which every
model statement goes through, adds each statement to the request's active
recorders: its SQL with a ? per bound value, never the values, so failure
messages can't put data in a log. Idle cost is one key check. Recorders nest
(each counts its own window), take an optional dataSource filter, and come
off in a catch-free finally, so a throwing or aborting callback can't leave
one behind. A query-cache hit sends no SQL and isn't counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ fc08716: code APPROVE (final sign-off after orch1's matrix run on this head and PR CI, Adobe legs especially)

  • Where statements are counted. The hook sits in Base.$executeQuery() after $assertBoundParameterCount() and before the cfquery. It's guarded by StructKeyExists(request.wheels, "$queryRecorders") && ArrayLen(…), so the idle cost is two key checks and a length check. A statement that then fails at the database is still counted, which is the right reading of "sent".
  • Recording. $recordQuery() writes ? for each bound part (and (?) for a list), collapses whitespace, and records the datasource. A recorder with a dataSource filter takes only matching statements (CompareNoCase). Every active recorder gets its own Duplicate, so nested recorders each count their own window and the outer one sees the inner statements, as designed and pinned by the spec.
  • Cleanup holds on every exit. $runRecordingQueries() pushes the recorder, runs the callback in a catch-free try, and stops it in the finally through $stopQueryRecorder(id). That's a call, with the loop inside the helper, not in the finally: invariant 12 and invariant 22, and the try/catch/finally guard is satisfied. Stopping by id (searching from the top) means a recorder that leaked from an aborted inner run can't be confused with this one.
  • Return value. The assertions return the callback's result only when it has one, so a void callback works.
  • The Adobe note. The two Adobe failures were the tag-callback interference in the INSERT case, fixed spec-only by switching to author (and asserting the create succeeded). The pre-existing leaky tag callbacks in callbacksSpec are worth their own issue. The matrix's Adobe legs are the real confirmation for this head.

Not blocking, a docs precision point. "No values recorded" holds for bound values. With parameterize = false, or SQL text that inlines literals, the literal is part of the statement text and will appear in a failure message. One sentence in the guide and .ai/testing.md ("bound values are replaced with ?; literals written into the SQL itself are shown") would make the guarantee exact.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review @ fc08716: source looks good. Final verdict after matrix 37339528267 and PR CI

  1. Zero cost and no behaviour change when no window is open. The only addition to Base.$executeQuery() is
    if (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "$queryRecorders") && ArrayLen(…)), ahead of the unchanged cfquery. Nothing else in the query path changed, and "records nothing while no recorder is active" pins it.
  2. Bound values are never recorded. $recordQuery() writes ? for every struct part ((?) for an IN list) and only ever concatenates the string parts.
    • Under parameterize = false the values still sit in struct parts until render time, so they're not recorded either.
    • Adapter rewrites happen in the adapter's own $performQuery() before super, so they're recorded with placeholders: SQL Server's STRING_SPLIT and CAST(? AS DATETIME), and the wide-decimal casts.
    • The only literal text that can appear is SQL the developer wrote into a where string themselves.
  3. The pop is catch-free. $runRecordingQueries() is a try { … } finally { $stopQueryRecorder(id) } with no catch, so it pops on a throw and, per invariant 22, on a BoxLang abort. "takes its recorder off when the callback throws" pins it. The write in the finally is local.run.queries, not inside a catch, so invariant 11 is fine.
  4. Nested windows. Every recorder on request.wheels.$queryRecorders receives each statement, and $stopQueryRecorder() removes by id from the end of the stack. So an inner window counts its own statements and the outer one counts them too ("counts its own window when nested").
  5. The dataSource filter compares case-insensitively against queryAttributes.datasource, which is the effective datasource, so it's tenant-routed. It's spec'd.
  6. Adobe. The recorded entries go straight into the stacked struct through request.wheels.$queryRecorders[i].queries, and $stopQueryRecorder() reads them back from the stack, not from local.recorder. That's the right way round for Adobe copying struct-held arrays by value (invariant 6). The matrix's Adobe legs will confirm it.

INFO (docs, optional). "What counts: every statement the model layer sends" is true for $executeQuery(). Framework features that call QueryExecute() directly aren't recorded:

  • withAdvisoryLock()'s lock statements;
  • the migration lock;
  • the job store;
  • SQL Server's compatibility-level and bind-type probes.

"Raw queryExecute()" in the doesn't-count list technically covers them, but naming advisory locks and jobs there would avoid a surprised count. Also, on SQL Server an assertQueriesMatch() pattern sees the adapter-rewritten SQL. Counts are unaffected.

The two .ai/testing.md copies are identical. The guide's assertQueries(1, () => …) example uses all-positional arguments, which is fine under the no-mixing rule.

On the matrix I'll read QueryAssertionsSpec on every leg, the Adobe legs closest, plus PR CI.

A failure lists each bound value as ?, but a value written into the SQL with
parameterize=false appears as written. Raw queryExecute isn't counted, which
includes advisory locks, the migration lock, the job store and SQL Server's
probes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

223 444 tests   218 323 ✅  1h 25m 49s ⏱️
 17 408 suites    5 121 💤
     32 files          0 ❌

Results for commit fc08716.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 9659550: APPROVE (final; code gate is matrix run 37339528267 on fc0871686, read leg by leg; PR CI checked on this head)

  • Matrix run 37339528267 (fc0871686). All 32 legs plus rustcfml succeeded, with 0 failures and 0 errors on every leg. QueryAssertionsSpec passed 8/8 on every leg, none skipped, including all six Adobe 2023 and all six Adobe 2025 legs (whose earlier local runs predated the INSERT-case fix) and BoxLang.
  • No extra cost or behaviour change when nothing is recording. The only change to Base.$executeQuery() is one if guarded by StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "$queryRecorders") && ArrayLen(…). With no recording window open, that's two key checks and a length check, and the statement takes exactly the path it always did.
  • Delta fc0871686..9659550b0 is docs only: the changelog fragment, both .ai/testing.md copies (byte-identical) and model-tests.mdx, with no .cfc/.cfm change. They now say bound values become ? and a value written into the SQL with parameterize = false appears as written.
  • PR CI on this head: 28 pass, 2 skipped, 0 failing or pending.

Follow-up, as agreed: the docblocks on WheelsTest.recordQueries and Base.$recordQuery should get the same "bound values" wording.

@bpamiri
bpamiri merged commit 726b400 into develop Oct 5, 2026
33 checks passed
@bpamiri
bpamiri deleted the peter/query-assertions branch October 5, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant