From fc087168621b2cc147a3f497c70f320f77504093 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 09:13:09 -0700 Subject: [PATCH 1/2] feat(test): assert how many SQL statements a spec's call sends 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 Signed-off-by: Peter Amiri --- changelog.d/query-assertions.added.md | 1 + cli/lucli/templates/app/.ai/testing.md | 12 ++ docs/consumer-ai/.ai/testing.md | 12 ++ vendor/wheels/WheelsTest.cfc | 161 ++++++++++++++++++ vendor/wheels/databaseAdapters/Base.cfc | 31 ++++ .../specs/wheelstest/QueryAssertionsSpec.cfc | 106 ++++++++++++ .../docs/v4-2-0/testing/model-tests.mdx | 40 +++++ 7 files changed, 363 insertions(+) create mode 100644 changelog.d/query-assertions.added.md create mode 100644 vendor/wheels/tests/specs/wheelstest/QueryAssertionsSpec.cfc diff --git a/changelog.d/query-assertions.added.md b/changelog.d/query-assertions.added.md new file mode 100644 index 0000000000..541d88e2ba --- /dev/null +++ b/changelog.d/query-assertions.added.md @@ -0,0 +1 @@ +- Specs can assert how many SQL statements a call sends: `assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count)`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)`, in every `wheels.WheelsTest` spec. They count what the model layer sends (a query-cache hit sends nothing), take an optional `dataSource` filter, nest, return the callback's result, and list the statements on failure with `?` for every value, never the values themselves diff --git a/cli/lucli/templates/app/.ai/testing.md b/cli/lucli/templates/app/.ai/testing.md index 83fe7e9aba..98c0a19af9 100644 --- a/cli/lucli/templates/app/.ai/testing.md +++ b/cli/lucli/templates/app/.ai/testing.md @@ -42,6 +42,18 @@ StructDelete(request.wheels, "$queryCache"); `$queryCache` is a reserved key under `request.wheels`; deleting it drops every model's cached finder results for the current request. +## Counting the queries a call sends (4.2+) + +`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with `?` for every value (values are never recorded). + +```cfm +var post = assertQueries(1, () => model("Post").findByKey(1)); +assertNoQueries(() => post.title); +assertNoQueriesMatch("comments", () => model("Post").findAll(include = "author")); +``` + +What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute`, transaction control, and test-client requests (a request of their own). + ## Which environment app specs run in `wheels test` runs your specs in the environment `.env` sets (`WHEELS_ENV=development` in a new app), not `testing`, so `config/testing/settings.cfm` doesn't apply to them. Put spec-wide defaults in `config/development/settings.cfm`. For example, to keep every spec from sending mail: diff --git a/docs/consumer-ai/.ai/testing.md b/docs/consumer-ai/.ai/testing.md index 83fe7e9aba..98c0a19af9 100644 --- a/docs/consumer-ai/.ai/testing.md +++ b/docs/consumer-ai/.ai/testing.md @@ -42,6 +42,18 @@ StructDelete(request.wheels, "$queryCache"); `$queryCache` is a reserved key under `request.wheels`; deleting it drops every model's cached finder results for the current request. +## Counting the queries a call sends (4.2+) + +`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with `?` for every value (values are never recorded). + +```cfm +var post = assertQueries(1, () => model("Post").findByKey(1)); +assertNoQueries(() => post.title); +assertNoQueriesMatch("comments", () => model("Post").findAll(include = "author")); +``` + +What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute`, transaction control, and test-client requests (a request of their own). + ## Which environment app specs run in `wheels test` runs your specs in the environment `.env` sets (`WHEELS_ENV=development` in a new app), not `testing`, so `config/testing/settings.cfm` doesn't apply to them. Put spec-wide defaults in `config/development/settings.cfm`. For example, to keep every spec from sending mail: diff --git a/vendor/wheels/WheelsTest.cfc b/vendor/wheels/WheelsTest.cfc index b637d57985..91cfc723a5 100644 --- a/vendor/wheels/WheelsTest.cfc +++ b/vendor/wheels/WheelsTest.cfc @@ -199,6 +199,167 @@ component extends="wheels.wheelstest.system.BaseSpec" { return local.root & local.relative; } + /** + * Runs `callback` and returns the SQL statements the model layer sent while it ran, as an array of + * `{sql, dataSource}`. Each `sql` has a `?` for every bound value; values are never recorded. + * Statements a request query cache answered aren't sent, so they aren't recorded; neither are raw + * `QueryExecute()` / `cfquery` calls, transaction control, or requests made through a TestClient. + * + * @callback A function to run. + * @dataSource Only record statements sent to this datasource. + */ + public array function recordQueries(required any callback, string dataSource = "") { + return $runRecordingQueries(callback = arguments.callback, dataSource = arguments.dataSource).queries; + } + + /** + * Fails the spec unless `callback` sends exactly `count` SQL statements (see `recordQueries()`), and + * returns what `callback` returned. The failure message lists the statements, with `?` for values. + * + * @count The number of statements expected. + * @callback A function to run. + * @dataSource Only count statements sent to this datasource. + */ + public any function assertQueries(required numeric count, required any callback, string dataSource = "") { + local.run = $runRecordingQueries(callback = arguments.callback, dataSource = arguments.dataSource); + if (ArrayLen(local.run.queries) != arguments.count) { + fail("Expected #arguments.count# #$queryNoun(arguments.count)#, ran #ArrayLen(local.run.queries)#." & $describeQueries(local.run.queries)); + } + if (StructKeyExists(local.run, "result")) { + return local.run.result; + } + } + + /** + * Fails the spec if `callback` sends any SQL statement, and returns what `callback` returned. + * + * @callback A function to run. + * @dataSource Only count statements sent to this datasource. + */ + public any function assertNoQueries(required any callback, string dataSource = "") { + return assertQueries(count = 0, callback = arguments.callback, dataSource = arguments.dataSource); + } + + /** + * Fails the spec unless at least one statement `callback` sends matches `pattern` (a regular + * expression, case-insensitive), or exactly `count` statements do when `count` is given. + * Returns what `callback` returned. + * + * @pattern A regular expression matched against each statement's SQL. + * @callback A function to run. + * @count The number of matching statements expected; any number above zero when empty. + * @dataSource Only consider statements sent to this datasource. + */ + public any function assertQueriesMatch(required string pattern, required any callback, string count = "", string dataSource = "") { + local.run = $runRecordingQueries(callback = arguments.callback, dataSource = arguments.dataSource); + local.matched = $matchingQueries(queries = local.run.queries, pattern = arguments.pattern); + if (Len(arguments.count) ? ArrayLen(local.matched) != Val(arguments.count) : !ArrayLen(local.matched)) { + fail( + "Expected " & (Len(arguments.count) ? Val(arguments.count) : "at least one") & " " + & $queryNoun(Len(arguments.count) ? Val(arguments.count) : 1) & " matching /#arguments.pattern#/, found #ArrayLen(local.matched)#." + & $describeQueries(local.run.queries) + ); + } + if (StructKeyExists(local.run, "result")) { + return local.run.result; + } + } + + /** + * Fails the spec if any statement `callback` sends matches `pattern` (a regular expression, + * case-insensitive), and returns what `callback` returned. + * + * @pattern A regular expression matched against each statement's SQL. + * @callback A function to run. + * @dataSource Only consider statements sent to this datasource. + */ + public any function assertNoQueriesMatch(required string pattern, required any callback, string dataSource = "") { + return assertQueriesMatch(pattern = arguments.pattern, callback = arguments.callback, count = "0", dataSource = arguments.dataSource); + } + + /** + * Internal. Runs `callback` with a query recorder of its own on the request's recorder stack and + * returns `{queries, result}` (`result` only when `callback` returned a value). A recorder nested in + * another sees only its own window; the outer one sees those statements too. The recorder is taken + * off in a catch-free finally (BoxLang skips a finally next to a catch when a request aborts), so a + * throwing callback can't leave it behind. + */ + public struct function $runRecordingQueries(required any callback, string dataSource = "") { + local.recorder = {id = CreateUUID(), dataSource = arguments.dataSource, queries = []}; + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + if (!StructKeyExists(request.wheels, "$queryRecorders")) { + request.wheels.$queryRecorders = []; + } + ArrayAppend(request.wheels.$queryRecorders, local.recorder); + local.run = {}; + local.fn = arguments.callback; + try { + local.result = local.fn(); + if (StructKeyExists(local, "result")) { + local.run.result = local.result; + } + } finally { + local.run.queries = $stopQueryRecorder(local.recorder.id); + } + return local.run; + } + + /** + * Internal. Takes the recorder `id` off the request's stack and returns what it recorded. Read from + * the stack, not from a copy: Adobe CF can copy a struct's array by value. + */ + public array function $stopQueryRecorder(required string id) { + local.rv = []; + if (!StructKeyExists(request, "wheels") || !StructKeyExists(request.wheels, "$queryRecorders")) { + return local.rv; + } + for (local.i = ArrayLen(request.wheels.$queryRecorders); local.i >= 1; local.i--) { + if (request.wheels.$queryRecorders[local.i].id == arguments.id) { + local.rv = request.wheels.$queryRecorders[local.i].queries; + ArrayDeleteAt(request.wheels.$queryRecorders, local.i); + break; + } + } + return local.rv; + } + + /** + * Internal. The recorded statements whose SQL matches `pattern`, case-insensitive. + */ + public array function $matchingQueries(required array queries, required string pattern) { + local.rv = []; + for (local.query in arguments.queries) { + if (ReFindNoCase(arguments.pattern, local.query.sql)) { + ArrayAppend(local.rv, local.query); + } + } + return local.rv; + } + + /** + * Internal. The recorded statements for a failure message, one per line and numbered. + */ + public string function $describeQueries(required array queries) { + if (!ArrayLen(arguments.queries)) { + return ""; + } + local.rv = " Statements:"; + local.iEnd = ArrayLen(arguments.queries); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv &= Chr(10) & "[#local.i#] " & arguments.queries[local.i].sql; + } + return local.rv; + } + + /** + * Internal. "query" or "queries". + */ + public string function $queryNoun(required numeric count) { + return arguments.count == 1 ? "query" : "queries"; + } + /** * Delete a directory and everything in it, symlink-safe. * diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index f3e9740513..2de7b58f4c 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -17,6 +17,10 @@ component output=false extends="wheels.Global"{ local.sqlArray = args.sql; local.sqlLen = arrayLen(sqlArray); $assertBoundParameterCount(sql = args.sql, parameterize = args.parameterize); + // A spec recording queries (recordQueries() / assertQueries()) sees this statement (F4). + if (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "$queryRecorders") && ArrayLen(request.wheels.$queryRecorders)) { + $recordQuery(sql = args.sql, queryAttributes = args.queryAttributes); + } // Build query cfquery(attributeCollection = args.queryAttributes) { @@ -121,6 +125,33 @@ component output=false extends="wheels.Global"{ return wheels.rv; } + /** + * Internal function. Adds a statement to every active query recorder (F4): its SQL with a `?` for + * each bound value (an IN list shows one `?` per list), never the values, so a failure message can't + * leak data into a log. A recorder with a `dataSource` filter only takes that datasource's statements. + */ + public void function $recordQuery(required array sql, required struct queryAttributes) { + local.text = ""; + for (local.part in arguments.sql) { + if (IsStruct(local.part)) { + local.text &= (StructKeyExists(local.part, "list") && local.part.list) ? "(?)" : "?"; + } else { + local.text &= " " & Trim(local.part); + } + } + local.entry = { + sql = Trim(ReReplace(local.text, "\s+", " ", "all")), + dataSource = StructKeyExists(arguments.queryAttributes, "datasource") ? arguments.queryAttributes.datasource : "" + }; + local.iEnd = ArrayLen(request.wheels.$queryRecorders); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.filter = request.wheels.$queryRecorders[local.i].dataSource; + if (!Len(local.filter) || CompareNoCase(local.filter, local.entry.dataSource) == 0) { + ArrayAppend(request.wheels.$queryRecorders[local.i].queries, Duplicate(local.entry)); + } + } + } + /** * Initialize and return the adapter object. */ diff --git a/vendor/wheels/tests/specs/wheelstest/QueryAssertionsSpec.cfc b/vendor/wheels/tests/specs/wheelstest/QueryAssertionsSpec.cfc new file mode 100644 index 0000000000..58ec28f97b --- /dev/null +++ b/vendor/wheels/tests/specs/wheelstest/QueryAssertionsSpec.cfc @@ -0,0 +1,106 @@ +/** + * Query assertions for specs (F4): recordQueries(), assertQueries(), assertNoQueries(), + * assertQueriesMatch() and assertNoQueriesMatch() count the SQL statements the model layer sends + * while a callback runs. Statements are recorded with a ? for each bound value, never the value. + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("Query assertions", () => { + + it("records each statement a callback sends, with ? for its values", () => { + var queries = recordQueries(() => { + g.model("author").findOne(where = "lastName = 'Djurner'"); + }); + expect(ArrayLen(queries)).toBe(1); + expect(queries[1].sql).toInclude("SELECT"); + expect(queries[1].sql).toInclude("?"); + expect(queries[1].sql).notToInclude("Djurner"); + expect(queries[1].dataSource).toBe(g.get("dataSourceName")); + }); + + it("returns the callback's result from assertQueries() and assertNoQueries()", () => { + var author = assertQueries(1, () => g.model("author").findOne(order = "id")); + expect(author).toBeWheelsModel(); + var name = assertNoQueries(() => author.firstName); + expect(name).toBe(author.firstName); + }); + + it("fails with the statements listed, values left out", () => { + var state = {message = ""}; + try { + assertQueries(2, () => g.model("author").findAll(where = "lastName = 'F4-secret-value'")); + } catch (any e) { + state.message = e.message; + } + expect(state.message).toInclude("Expected 2 queries, ran 1."); + expect(state.message).toInclude("[1] SELECT"); + expect(state.message).notToInclude("F4-secret-value"); + }); + + it("matches statements against a pattern, or finds none", () => { + // author: no spec registers callbacks on it, so the create's INSERT always runs. + var created = assertQueriesMatch("^INSERT", () => g.model("author").create(firstName = "F4", lastName = "Insert", transaction = "rollback")); + expect(created.hasErrors()).toBeFalse(); + assertQueriesMatch("FROM", () => g.model("author").findAll(), 1); + assertNoQueriesMatch("c_o_r_e_comments", () => g.model("author").findAll()); + var state = {message = ""}; + try { + assertNoQueriesMatch("c_o_r_e_authors", () => g.model("author").findAll()); + } catch (any e) { + state.message = e.message; + } + expect(state.message).toInclude("Expected 0 queries matching"); + }); + + it("counts its own window when nested in another recorder", () => { + var inner = {count = -1}; + var outer = recordQueries(() => { + g.model("author").findOne(order = "id"); + inner.count = ArrayLen(recordQueries(() => g.model("post").findOne(order = "id"))); + assertQueries(1, () => g.model("author").count()); + }); + expect(inner.count).toBe(1); + expect(ArrayLen(outer)).toBe(3, "the outer recorder sees every statement in its window"); + }); + + it("takes its recorder off when the callback throws", () => { + var before = (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "$queryRecorders")) ? ArrayLen(request.wheels.$queryRecorders) : 0; + var state = {threw = false}; + try { + recordQueries(() => { + g.model("author").findOne(order = "id"); + Throw(type = "Wheels.F4SpecBoom", message = "boom"); + }); + } catch (any e) { + state.threw = true; + } + expect(state.threw).toBeTrue(); + expect(ArrayLen(request.wheels.$queryRecorders)).toBe(before); + expect(ArrayLen(recordQueries(() => g.model("author").findOne(order = "id")))).toBe(1); + }); + + it("filters by datasource", () => { + var ds = g.get("dataSourceName"); + expect(ArrayLen(recordQueries(() => g.model("author").findOne(order = "id"), ds))).toBe(1); + expect(ArrayLen(recordQueries(() => g.model("author").findOne(order = "id"), "wheels_f4_other_ds"))).toBe(0); + assertNoQueries(() => g.model("author").findOne(order = "id"), "wheels_f4_other_ds"); + }); + + it("records nothing while no recorder is active", () => { + g.model("author").findOne(order = "id"); + expect( + !StructKeyExists(request, "wheels") + || !StructKeyExists(request.wheels, "$queryRecorders") + || !ArrayLen(request.wheels.$queryRecorders) + ).toBeTrue(); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx b/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx index bc99fa9a3b..e4f523e9be 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx @@ -201,6 +201,46 @@ describe("Post scopes", () => { `published()` and `draft()` above are auto-generated from `enum(property="status", values="draft,published,archived")`. Named scopes declared with `scope(name="active", where="...")` work identically. +## Counting queries + +From Wheels 4.2, a spec can assert how many SQL statements a call sends, which makes N+1 queries, missing caches and unexpected writes testable: + +| Function | Fails unless | +|---|---| +| `assertQueries(count, callback)` | `callback` sends exactly `count` statements | +| `assertNoQueries(callback)` | `callback` sends none | +| `assertQueriesMatch(pattern, callback, count)` | at least one statement (or exactly `count`) matches the regular expression `pattern`, case-insensitive | +| `assertNoQueriesMatch(pattern, callback)` | no statement matches `pattern` | +| `recordQueries(callback)` | never fails: returns the statements as an array of `{sql, dataSource}` | + +```cfm {test:compile} +component extends="wheels.WheelsTest" { + function run() { + describe("Post listing", () => { + it("loads authors in the same query", () => { + var posts = assertQueries(1, () => model("Post").findAll(include="author")); + expect(posts.recordCount).toBeGT(0); + }); + + it("doesn't touch the comments table", () => { + assertNoQueriesMatch("comments", () => model("Post").findAll()); + }); + }); + } +} +``` + +- The assertions return what `callback` returned, so a call can stay inline. +- Every function takes an optional `dataSource` that counts only statements sent to that datasource, which is useful in a multi-tenant app. +- A failure lists the statements that ran, with a `?` for each value. Values are never recorded, so a failure can't put data into a CI log. +- Assertions can be nested: each counts its own callback's statements. +- **What counts:** every statement the model layer sends, including finders, `count()`, `save()`, `create()`, `update()`, `delete()`, `updateAll()` and `deleteAll()`. +- **What doesn't count:** + - a finder answered from the per-request query cache, because it sends no SQL (pass `reload=true` to force the query); + - raw `queryExecute()` / `cfquery`; + - transaction control; + - requests made through the test client, which run in a request of their own. + ## Test isolation WheelsTest does not wrap each `it` in a transaction. The database state carries across specs within a run. Two mechanisms keep things predictable: From 9659550b01eb6a5679f40af6ec073501c423fd0a Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 09:34:27 -0700 Subject: [PATCH 2/2] docs(test): say that bound values become ? and name what isn't counted 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 Signed-off-by: Peter Amiri --- changelog.d/query-assertions.added.md | 2 +- cli/lucli/templates/app/.ai/testing.md | 4 ++-- docs/consumer-ai/.ai/testing.md | 4 ++-- .../guides/src/content/docs/v4-2-0/testing/model-tests.mdx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/changelog.d/query-assertions.added.md b/changelog.d/query-assertions.added.md index 541d88e2ba..f5375ecba8 100644 --- a/changelog.d/query-assertions.added.md +++ b/changelog.d/query-assertions.added.md @@ -1 +1 @@ -- Specs can assert how many SQL statements a call sends: `assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count)`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)`, in every `wheels.WheelsTest` spec. They count what the model layer sends (a query-cache hit sends nothing), take an optional `dataSource` filter, nest, return the callback's result, and list the statements on failure with `?` for every value, never the values themselves +- Specs can assert how many SQL statements a call sends: `assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count)`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)`, in every `wheels.WheelsTest` spec. They count what the model layer sends (a query-cache hit sends nothing), take an optional `dataSource` filter, nest, return the callback's result, and list the statements on failure with each bound value as `?` diff --git a/cli/lucli/templates/app/.ai/testing.md b/cli/lucli/templates/app/.ai/testing.md index 98c0a19af9..5557acafdd 100644 --- a/cli/lucli/templates/app/.ai/testing.md +++ b/cli/lucli/templates/app/.ai/testing.md @@ -44,7 +44,7 @@ StructDelete(request.wheels, "$queryCache"); ## Counting the queries a call sends (4.2+) -`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with `?` for every value (values are never recorded). +`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with each bound value as `?` (a value written into the SQL with `parameterize = false` appears as written). ```cfm var post = assertQueries(1, () => model("Post").findByKey(1)); @@ -52,7 +52,7 @@ assertNoQueries(() => post.title); assertNoQueriesMatch("comments", () => model("Post").findAll(include = "author")); ``` -What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute`, transaction control, and test-client requests (a request of their own). +What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute` (including advisory locks, the migration lock, the job store and SQL Server's probes), transaction control, and test-client requests (a request of their own). ## Which environment app specs run in diff --git a/docs/consumer-ai/.ai/testing.md b/docs/consumer-ai/.ai/testing.md index 98c0a19af9..5557acafdd 100644 --- a/docs/consumer-ai/.ai/testing.md +++ b/docs/consumer-ai/.ai/testing.md @@ -44,7 +44,7 @@ StructDelete(request.wheels, "$queryCache"); ## Counting the queries a call sends (4.2+) -`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with `?` for every value (values are never recorded). +`assertQueries(count, callback)`, `assertNoQueries(callback)`, `assertQueriesMatch(pattern, callback, count = "")`, `assertNoQueriesMatch(pattern, callback)` and `recordQueries(callback)` (returns `[{sql, dataSource}]`) count the SQL statements the model layer sends while `callback` runs. They're in every `wheels.WheelsTest` spec. They return the callback's result, take an optional `dataSource` filter, nest, and list the statements on failure with each bound value as `?` (a value written into the SQL with `parameterize = false` appears as written). ```cfm var post = assertQueries(1, () => model("Post").findByKey(1)); @@ -52,7 +52,7 @@ assertNoQueries(() => post.title); assertNoQueriesMatch("comments", () => model("Post").findAll(include = "author")); ``` -What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute`, transaction control, and test-client requests (a request of their own). +What isn't counted: a finder answered from the per-request query cache above (no SQL is sent; pass `reload = true`), raw `queryExecute` (including advisory locks, the migration lock, the job store and SQL Server's probes), transaction control, and test-client requests (a request of their own). ## Which environment app specs run in diff --git a/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx b/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx index e4f523e9be..a311b6f9c9 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/testing/model-tests.mdx @@ -232,12 +232,12 @@ component extends="wheels.WheelsTest" { - The assertions return what `callback` returned, so a call can stay inline. - Every function takes an optional `dataSource` that counts only statements sent to that datasource, which is useful in a multi-tenant app. -- A failure lists the statements that ran, with a `?` for each value. Values are never recorded, so a failure can't put data into a CI log. +- A failure lists the statements that ran, with each bound value replaced by `?`, so a failure can't put bound data into a CI log. A value written into the SQL text itself (`parameterize=false`) appears as written. - Assertions can be nested: each counts its own callback's statements. - **What counts:** every statement the model layer sends, including finders, `count()`, `save()`, `create()`, `update()`, `delete()`, `updateAll()` and `deleteAll()`. - **What doesn't count:** - a finder answered from the per-request query cache, because it sends no SQL (pass `reload=true` to force the query); - - raw `queryExecute()` / `cfquery`; + - raw `queryExecute()` / `cfquery`, including the framework features built on it: advisory locks, the migration lock, the job store and SQL Server's settings probes; - transaction control; - requests made through the test client, which run in a request of their own.