Skip to content

Commit a5b58ea

Browse files
feat(model): add includeCalculated to additively opt in select=false SQL properties
Adds an `includeCalculated` argument to findAll(), findOne(), and findByKey() that opts already-declared calculated SQL properties (property(name=..., sql=..., select=false)) back into a single finder. Unlike `select`, it is additive — the named calculated properties are merged on top of the default column list inside $createSQLFieldList rather than replacing it, so the rest of the record is still returned. This closes the inverse of the existing select=false declaration: a property kept off the hot path can now be pulled back in per-call without hand-listing every other column. Unknown names throw Wheels.CalculatedPropertyNotFound in development/testing and are ignored in production, mirroring existing dev-only validation patterns. Pure list manipulation — no closures, struct member functions, or other cross-engine traps. Fixes #3252 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent fe5fc42 commit a5b58ea

4 files changed

Lines changed: 119 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added an `includeCalculated` argument to `findAll()`, `findOne()`, and `findByKey()` for additively opting a `select=false` calculated SQL property back into a single finder — e.g. `model("User").findAll(includeCalculated="fullName")`. Unlike `select`, it merges the named calculated properties on top of the default column list rather than replacing it, so the rest of the record is still returned. Unknown names throw `Wheels.CalculatedPropertyNotFound` in development/testing and are ignored in production (#3252)

vendor/wheels/model/read.cfc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ component {
1010
* @order Maps to the `ORDER` BY clause of the query. You do not need to specify the table name(s); Wheels will do that for you.
1111
* @group Maps to the `GROUP BY` clause of the query. You do not need to specify the table name(s); Wheels will do that for you.
1212
* @select Determines how the `SELECT` clause for the query used to return data will look. You can pass in a list of the properties (which map to columns) that you want returned from your table(s). If you don't set this argument at all, Wheels will select all properties from your table(s). If you specify a table name (e.g. `users.email`) or alias a column (e.g. `fn AS firstName`) in the list, then the entire list will be passed through unchanged and used in the `SELECT` clause of the query. By default, all column names in tables joined via the `include` argument will be prepended with the singular version of the included table name.
13+
* @includeCalculated List of calculated property names (declared via `property(name="...", sql="...", select=false)`) to additively opt into this finder's `SELECT` clause. Unlike `select`, this does not replace the default column list — the named calculated properties are merged on top of all default columns, so the rest of the record is still returned. Useful for pulling a `select=false` computed property back in on a single finder without spelling out every other column. Unknown names throw `Wheels.CalculatedPropertyNotFound` in `development`/`testing` and are ignored in `production`.
1314
* @distinct Whether to add the `DISTINCT` keyword to your `SELECT` clause. Wheels will, when necessary, add this automatically (when using pagination and a `hasMany` association is used in the `include` argument, to name one example).
1415
* @include Associations that should be included in the query using `INNER` or `LEFT OUTER` joins (which join type that is used depends on how the association has been set up in your model). If all included associations are set on the current model, you can specify them in a list (e.g. `department,addresses,emails`). You can build more complex include strings by using parentheses when the association is set on an included model, like `album(artist(genre))`, for example. These complex `include` strings only work when `returnAs` is set to `query` though.
1516
* @maxRows Maximum number of records to retrieve. Passed on to the `maxRows` `cfquery` attribute. The default, `-1`, means that all records will be retrieved.
@@ -32,6 +33,7 @@ component {
3233
string order,
3334
string group,
3435
string select = "",
36+
string includeCalculated = "",
3537
boolean distinct = "false",
3638
string include = "",
3739
numeric maxRows = "-1",
@@ -214,7 +216,8 @@ component {
214216
include = arguments.include,
215217
includeSoftDeletes = arguments.includeSoftDeletes,
216218
list = arguments.select,
217-
returnAs = arguments.returnAs
219+
returnAs = arguments.returnAs,
220+
includeCalculated = arguments.includeCalculated
218221
);
219222
// Strip dialect quotes: $createSQLFieldList now quotes identifiers; the bare-identifier regex below requires unquoted input.
220223
local.columns = variables.wheels.class.adapter.$stripIdentifierQuotes(local.columns);
@@ -247,7 +250,8 @@ component {
247250
select = arguments.select,
248251
include = arguments.include,
249252
includeSoftDeletes = arguments.includeSoftDeletes,
250-
returnAs = arguments.returnAs
253+
returnAs = arguments.returnAs,
254+
includeCalculated = arguments.includeCalculated
251255
)
252256
);
253257
ArrayAppend(
@@ -407,6 +411,7 @@ component {
407411
*
408412
* @key Primary key value(s) of the record. Separate with comma if passing in multiple primary key values. Accepts a string, list, or a numeric value.
409413
* @select [see:findAll].
414+
* @includeCalculated [see:findAll].
410415
* @include [see:findAll].
411416
* @handle Handle to use for the query. This is used to set the name of the query in the debug output (which otherwise defaults to `userFindOneQuery` for example).
412417
* @cache [see:findAll].
@@ -420,6 +425,7 @@ component {
420425
public any function findByKey(
421426
required any key,
422427
string select = "",
428+
string includeCalculated = "",
423429
string include = "",
424430
string handle = "query",
425431
any cache = "",
@@ -457,6 +463,7 @@ component {
457463
* @where [see:findAll].
458464
* @order [see:findAll].
459465
* @select [see:findAll].
466+
* @includeCalculated [see:findAll].
460467
* @include [see:findAll].
461468
* @handle [see:findByKey].
462469
* @cache [see:findAll].
@@ -471,6 +478,7 @@ component {
471478
string where = "",
472479
string order = "",
473480
string select = "",
481+
string includeCalculated = "",
474482
string include = "",
475483
string handle = "query",
476484
any cache = "",

vendor/wheels/model/sql.cfc

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,14 +411,16 @@ component {
411411
required string select,
412412
required string include,
413413
boolean includeSoftDeletes = "false",
414-
required string returnAs
414+
required string returnAs,
415+
string includeCalculated = ""
415416
) {
416417
local.rv = $createSQLFieldList(
417418
clause = "select",
418419
list = arguments.select,
419420
include = arguments.include,
420421
includeSoftDeletes = arguments.includeSoftDeletes,
421-
returnAs = arguments.returnAs
422+
returnAs = arguments.returnAs,
423+
includeCalculated = arguments.includeCalculated
422424
);
423425

424426
// Look for " AS " followed by text containing multiple dots (namespaced aliases)
@@ -471,7 +473,8 @@ component {
471473
required string include,
472474
required string returnAs,
473475
boolean includeSoftDeletes = "false",
474-
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#"
476+
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#",
477+
string includeCalculated = ""
475478
) {
476479
// setup an array containing class info for current class and all the ones that should be included
477480
local.classes = [];
@@ -504,6 +507,36 @@ component {
504507
}
505508
}
506509

510+
// Additively opt in any calculated properties named via `includeCalculated` (issue #3252).
511+
// These are typically declared `select=false`, so they are absent from the default list
512+
// above; merging them here keeps every base column in place (additive, never replacing).
513+
if (Len(arguments.includeCalculated)) {
514+
local.calcArray = ListToArray(arguments.includeCalculated);
515+
local.calcEnd = ArrayLen(local.calcArray);
516+
for (local.c = 1; local.c <= local.calcEnd; local.c++) {
517+
local.calcName = Trim(local.calcArray[local.c]);
518+
if (!Len(local.calcName)) {
519+
continue;
520+
}
521+
if (!StructKeyExists(variables.wheels.class.calculatedProperties, local.calcName)) {
522+
// Dev/testing fail loud on a typo; no-op in production (mirrors existing
523+
// dev-only validation such as Wheels.PaginationNav.InvalidArgument).
524+
if (ListFindNoCase("development,testing", get("environment"))) {
525+
Throw(
526+
type = "Wheels.CalculatedPropertyNotFound",
527+
message = "The calculated property `#local.calcName#` was not found on the `#variables.wheels.class.modelName#` model.",
528+
extendedInfo = "The `includeCalculated` argument only accepts the names of calculated properties declared via `property(name=""..."", sql=""..."")` in the model's `config()`. Declared calculated properties: #StructKeyList(variables.wheels.class.calculatedProperties)#."
529+
);
530+
}
531+
continue;
532+
}
533+
// dedup: $createSQLFieldList already de-duplicates, but skip obvious repeats
534+
if (!ListFindNoCase(arguments.list, local.calcName)) {
535+
arguments.list = ListAppend(arguments.list, local.calcName);
536+
}
537+
}
538+
}
539+
507540
// go through the properties and map them to the database unless the developer passed in a table name or an alias in which case we assume they know what they're doing and leave the select clause as is
508541

509542
/* To fix the issue below:
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
g = application.wo;
6+
7+
describe("includeCalculated finder argument (issue ##3252)", () => {
8+
9+
it("is additive in the generated SELECT — opts in a select=false calculated property without dropping base columns", () => {
10+
// `titleAlias` is declared `select=false` on Post, so it is absent by default.
11+
baseClause = g.model("post").$selectClause(
12+
select = "",
13+
include = "",
14+
returnAs = "query"
15+
);
16+
expect(baseClause).notToInclude("AS titleAlias");
17+
18+
// Opting it in must ADD it on top of the default columns, not replace them.
19+
optedIn = g.model("post").$selectClause(
20+
select = "",
21+
include = "",
22+
returnAs = "query",
23+
includeCalculated = "titleAlias"
24+
);
25+
expect(optedIn).toInclude("AS titleAlias");
26+
// base columns are still present (additive, not replacing)
27+
expect(optedIn).toInclude("title");
28+
});
29+
30+
it("supports a comma list of calculated property names", () => {
31+
clause = g.model("post").$selectClause(
32+
select = "",
33+
include = "",
34+
returnAs = "query",
35+
includeCalculated = "titleAlias,createdAtAlias"
36+
);
37+
expect(clause).toInclude("AS titleAlias");
38+
expect(clause).toInclude("AS createdAtAlias");
39+
});
40+
41+
it("populates the opted-in property on a real finder while base columns remain", () => {
42+
post = g.model("post").findOne(includeCalculated = "titleAlias");
43+
expect(IsObject(post)).toBeTrue();
44+
// base property still present
45+
expect(StructKeyExists(post, "title")).toBeTrue();
46+
// the opted-in calculated property is now populated and mirrors `title`
47+
expect(StructKeyExists(post, "titleAlias")).toBeTrue();
48+
expect(post.titleAlias).toBe(post.title);
49+
});
50+
51+
it("leaves the opted-in property off the default finder", () => {
52+
post = g.model("post").findOne();
53+
expect(IsObject(post)).toBeTrue();
54+
expect(StructKeyExists(post, "titleAlias")).toBeFalse();
55+
});
56+
57+
it("throws Wheels.CalculatedPropertyNotFound for an unknown name in development/testing", () => {
58+
expect(() => {
59+
g.model("post").$selectClause(
60+
select = "",
61+
include = "",
62+
returnAs = "query",
63+
includeCalculated = "thisDoesNotExist"
64+
);
65+
}).toThrow(type = "Wheels.CalculatedPropertyNotFound");
66+
});
67+
68+
});
69+
70+
}
71+
72+
}

0 commit comments

Comments
 (0)