Skip to content

Commit c7ca961

Browse files
wheels-bot[bot]github-actions[bot]Peter Amiri
authored
feat(model): add includeCalculated to additively opt in select=false SQL properties (#3254)
* 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> * docs: document includeCalculated finder argument for select=false SQL properties Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs: fix premature code fence in calculated-properties snippet The includeCalculated usage example introduced a closing code fence mid-section, ejecting the method-based calculated-property example out of the cfm block and orphaning the trailing fence (12 -> 13 fences). Remove the stray fence so the whole section renders as one code block; fence parity restored to 12, matching develop. Signed-off-by: Peter Amiri <petera@pai.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <petera@pai.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <petera@pai.com>
1 parent 4ac94b7 commit c7ca961

7 files changed

Lines changed: 151 additions & 6 deletions

File tree

.ai/wheels/snippets/model-snippets.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,17 @@ function recent(days=30) {
151151
## Calculated Properties
152152
```cfm
153153
function config() {
154-
// SQL-based calculated property
154+
// SQL-based calculated property — included in every SELECT by default
155155
property(name="orderTotal", sql="(SELECT SUM(amount) FROM order_items WHERE order_id = orders.id)");
156+
157+
// Keep off the hot path with select=false; opt in per-call with includeCalculated
158+
property(name="fullName", sql="firstName || ' ' || lastName", select=false);
156159
}
157160
161+
// Opt a select=false property back into one finder (additive — base columns still selected)
162+
user = model("User").findOne(includeCalculated="fullName");
163+
order = model("Order").findAll(includeCalculated="orderTotal,shippingCost");
164+
158165
// Method-based calculated property
159166
function displayName() {
160167
if (Len(this.nickName)) {

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ component extends="Model" {
280280
// Callbacks
281281
beforeSave("sanitizeInput");
282282
283+
// Calculated SQL properties — select=false keeps them off the default SELECT (hot path)
284+
property(name="fullName", sql="firstName || ' ' || lastName", select=false);
285+
283286
// Query scopes — reusable, composable query fragments
284287
scope(name="active", where="status = 'active'");
285288
scope(name="recent", order="createdAt DESC");
@@ -299,6 +302,7 @@ component extends="Model" {
299302
Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`.
300303
Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`.
301304
Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`.
305+
Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing.
302306

303307
### Scopes / Enums / Builder / Batch
304308

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
@@ -434,14 +434,16 @@ component {
434434
required string select,
435435
required string include,
436436
boolean includeSoftDeletes = "false",
437-
required string returnAs
437+
required string returnAs,
438+
string includeCalculated = ""
438439
) {
439440
local.rv = $createSQLFieldList(
440441
clause = "select",
441442
list = arguments.select,
442443
include = arguments.include,
443444
includeSoftDeletes = arguments.includeSoftDeletes,
444-
returnAs = arguments.returnAs
445+
returnAs = arguments.returnAs,
446+
includeCalculated = arguments.includeCalculated
445447
);
446448

447449
// Look for " AS " followed by text containing multiple dots (namespaced aliases)
@@ -494,7 +496,8 @@ component {
494496
required string include,
495497
required string returnAs,
496498
boolean includeSoftDeletes = "false",
497-
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#"
499+
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#",
500+
string includeCalculated = ""
498501
) {
499502
// setup an array containing class info for current class and all the ones that should be included
500503
local.classes = [];
@@ -527,6 +530,36 @@ component {
527530
}
528531
}
529532

533+
// Additively opt in any calculated properties named via `includeCalculated` (issue #3252).
534+
// These are typically declared `select=false`, so they are absent from the default list
535+
// above; merging them here keeps every base column in place (additive, never replacing).
536+
if (Len(arguments.includeCalculated)) {
537+
local.calcArray = ListToArray(arguments.includeCalculated);
538+
local.calcEnd = ArrayLen(local.calcArray);
539+
for (local.c = 1; local.c <= local.calcEnd; local.c++) {
540+
local.calcName = Trim(local.calcArray[local.c]);
541+
if (!Len(local.calcName)) {
542+
continue;
543+
}
544+
if (!StructKeyExists(variables.wheels.class.calculatedProperties, local.calcName)) {
545+
// Dev/testing fail loud on a typo; no-op in production (mirrors existing
546+
// dev-only validation such as Wheels.PaginationNav.InvalidArgument).
547+
if (ListFindNoCase("development,testing", get("environment"))) {
548+
Throw(
549+
type = "Wheels.CalculatedPropertyNotFound",
550+
message = "The calculated property `#local.calcName#` was not found on the `#variables.wheels.class.modelName#` model.",
551+
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)#."
552+
);
553+
}
554+
continue;
555+
}
556+
// dedup: $createSQLFieldList already de-duplicates, but skip obvious repeats
557+
if (!ListFindNoCase(arguments.list, local.calcName)) {
558+
arguments.list = ListAppend(arguments.list, local.calcName);
559+
}
560+
}
561+
}
562+
530563
// 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
531564

532565
/* 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+
}

web/sites/guides/src/content/docs/v4-0-0/basics/models-and-the-orm.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,26 @@ component extends="Model" {
6565

6666
Composite keys are also supported — pass a comma-separated list to `setPrimaryKey("tenantId,entryId")`. Most apps never need them.
6767

68+
## Calculated SQL properties
69+
70+
A `property()` declaration can carry a `sql` expression that Wheels evaluates as a computed `SELECT` column. Pass `select=false` to keep the property off the default `SELECT` (reducing hot-path cost), then opt it back into a specific finder with `includeCalculated`:
71+
72+
```cfm
73+
component extends="Model" {
74+
function config() {
75+
property(name="fullName", sql="firstName || ' ' || lastName", select=false);
76+
}
77+
}
78+
79+
// Additive — all base columns are still selected; fullName is merged on top
80+
user = model("User").findOne(includeCalculated="fullName");
81+
82+
// Comma-list for multiple properties; composes with other finder arguments
83+
users = model("User").active().findAll(include="role", includeCalculated="fullName", page=1, perPage=25);
84+
```
85+
86+
Unknown names passed to `includeCalculated` throw `Wheels.CalculatedPropertyNotFound` in `development` and `testing`, and are silently ignored in `production`.
87+
6888
## Finders
6989

7090
The finders read from the database. Every finder below is a method on the class, called via `model("Name")`. The calls return different shapes depending on whether you're loading many, one, or just asking a question.

0 commit comments

Comments
 (0)