Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/query-assertions.added.md
Original file line number Diff line number Diff line change
@@ -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 each bound value as `?`
12 changes: 12 additions & 0 deletions cli/lucli/templates/app/.ai/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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));
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` (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

`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:
Expand Down
12 changes: 12 additions & 0 deletions docs/consumer-ai/.ai/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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));
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` (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

`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:
Expand Down
161 changes: 161 additions & 0 deletions vendor/wheels/WheelsTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
31 changes: 31 additions & 0 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*/
Expand Down
106 changes: 106 additions & 0 deletions vendor/wheels/tests/specs/wheelstest/QueryAssertionsSpec.cfc
Original file line number Diff line number Diff line change
@@ -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();
});

});

}

}
Loading
Loading