diff --git a/.ai/wheels/snippets/model-snippets.md b/.ai/wheels/snippets/model-snippets.md index db919550ba..bb26e3c118 100755 --- a/.ai/wheels/snippets/model-snippets.md +++ b/.ai/wheels/snippets/model-snippets.md @@ -151,10 +151,17 @@ function recent(days=30) { ## Calculated Properties ```cfm function config() { - // SQL-based calculated property + // SQL-based calculated property — included in every SELECT by default property(name="orderTotal", sql="(SELECT SUM(amount) FROM order_items WHERE order_id = orders.id)"); + + // Keep off the hot path with select=false; opt in per-call with includeCalculated + property(name="fullName", sql="firstName || ' ' || lastName", select=false); } +// Opt a select=false property back into one finder (additive — base columns still selected) +user = model("User").findOne(includeCalculated="fullName"); +order = model("Order").findAll(includeCalculated="orderTotal,shippingCost"); + // Method-based calculated property function displayName() { if (Len(this.nickName)) { diff --git a/CLAUDE.md b/CLAUDE.md index 77d33bcb8d..e4c991455d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,6 +280,9 @@ component extends="Model" { // Callbacks beforeSave("sanitizeInput"); + // Calculated SQL properties — select=false keeps them off the default SELECT (hot path) + property(name="fullName", sql="firstName || ' ' || lastName", select=false); + // Query scopes — reusable, composable query fragments scope(name="active", where="status = 'active'"); scope(name="recent", order="createdAt DESC"); @@ -299,6 +302,7 @@ component extends="Model" { Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`. Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`. Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`. +Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing. ### Scopes / Enums / Builder / Batch diff --git a/changelog.d/3252-include-calculated.added.md b/changelog.d/3252-include-calculated.added.md new file mode 100644 index 0000000000..8472a16380 --- /dev/null +++ b/changelog.d/3252-include-calculated.added.md @@ -0,0 +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) diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index 6264f25346..330ba091f4 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -10,6 +10,7 @@ component { * @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. * @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. * @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. + * @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`. * @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). * @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. * @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 { string order, string group, string select = "", + string includeCalculated = "", boolean distinct = "false", string include = "", numeric maxRows = "-1", @@ -214,7 +216,8 @@ component { include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, list = arguments.select, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ); // Strip dialect quotes: $createSQLFieldList now quotes identifiers; the bare-identifier regex below requires unquoted input. local.columns = variables.wheels.class.adapter.$stripIdentifierQuotes(local.columns); @@ -247,7 +250,8 @@ component { select = arguments.select, include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ) ); ArrayAppend( @@ -407,6 +411,7 @@ component { * * @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. * @select [see:findAll]. + * @includeCalculated [see:findAll]. * @include [see:findAll]. * @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). * @cache [see:findAll]. @@ -420,6 +425,7 @@ component { public any function findByKey( required any key, string select = "", + string includeCalculated = "", string include = "", string handle = "query", any cache = "", @@ -457,6 +463,7 @@ component { * @where [see:findAll]. * @order [see:findAll]. * @select [see:findAll]. + * @includeCalculated [see:findAll]. * @include [see:findAll]. * @handle [see:findByKey]. * @cache [see:findAll]. @@ -471,6 +478,7 @@ component { string where = "", string order = "", string select = "", + string includeCalculated = "", string include = "", string handle = "query", any cache = "", diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index 773068beb9..26505a6d88 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -411,14 +411,16 @@ component { required string select, required string include, boolean includeSoftDeletes = "false", - required string returnAs + required string returnAs, + string includeCalculated = "" ) { local.rv = $createSQLFieldList( clause = "select", list = arguments.select, include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ); // Look for " AS " followed by text containing multiple dots (namespaced aliases) @@ -471,7 +473,8 @@ component { required string include, required string returnAs, boolean includeSoftDeletes = "false", - boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#" + boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#", + string includeCalculated = "" ) { // setup an array containing class info for current class and all the ones that should be included local.classes = []; @@ -504,6 +507,36 @@ component { } } + // Additively opt in any calculated properties named via `includeCalculated` (issue #3252). + // These are typically declared `select=false`, so they are absent from the default list + // above; merging them here keeps every base column in place (additive, never replacing). + if (Len(arguments.includeCalculated)) { + local.calcArray = ListToArray(arguments.includeCalculated); + local.calcEnd = ArrayLen(local.calcArray); + for (local.c = 1; local.c <= local.calcEnd; local.c++) { + local.calcName = Trim(local.calcArray[local.c]); + if (!Len(local.calcName)) { + continue; + } + if (!StructKeyExists(variables.wheels.class.calculatedProperties, local.calcName)) { + // Dev/testing fail loud on a typo; no-op in production (mirrors existing + // dev-only validation such as Wheels.PaginationNav.InvalidArgument). + if (ListFindNoCase("development,testing", get("environment"))) { + Throw( + type = "Wheels.CalculatedPropertyNotFound", + message = "The calculated property `#local.calcName#` was not found on the `#variables.wheels.class.modelName#` model.", + 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)#." + ); + } + continue; + } + // dedup: $createSQLFieldList already de-duplicates, but skip obvious repeats + if (!ListFindNoCase(arguments.list, local.calcName)) { + arguments.list = ListAppend(arguments.list, local.calcName); + } + } + } + // 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 /* To fix the issue below: diff --git a/vendor/wheels/tests/specs/model/includeCalculatedSpec.cfc b/vendor/wheels/tests/specs/model/includeCalculatedSpec.cfc new file mode 100644 index 0000000000..d4cb122305 --- /dev/null +++ b/vendor/wheels/tests/specs/model/includeCalculatedSpec.cfc @@ -0,0 +1,72 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("includeCalculated finder argument (issue ##3252)", () => { + + it("is additive in the generated SELECT — opts in a select=false calculated property without dropping base columns", () => { + // `titleAlias` is declared `select=false` on Post, so it is absent by default. + baseClause = g.model("post").$selectClause( + select = "", + include = "", + returnAs = "query" + ); + expect(baseClause).notToInclude("AS titleAlias"); + + // Opting it in must ADD it on top of the default columns, not replace them. + optedIn = g.model("post").$selectClause( + select = "", + include = "", + returnAs = "query", + includeCalculated = "titleAlias" + ); + expect(optedIn).toInclude("AS titleAlias"); + // base columns are still present (additive, not replacing) + expect(optedIn).toInclude("title"); + }); + + it("supports a comma list of calculated property names", () => { + clause = g.model("post").$selectClause( + select = "", + include = "", + returnAs = "query", + includeCalculated = "titleAlias,createdAtAlias" + ); + expect(clause).toInclude("AS titleAlias"); + expect(clause).toInclude("AS createdAtAlias"); + }); + + it("populates the opted-in property on a real finder while base columns remain", () => { + post = g.model("post").findOne(includeCalculated = "titleAlias"); + expect(IsObject(post)).toBeTrue(); + // base property still present + expect(StructKeyExists(post, "title")).toBeTrue(); + // the opted-in calculated property is now populated and mirrors `title` + expect(StructKeyExists(post, "titleAlias")).toBeTrue(); + expect(post.titleAlias).toBe(post.title); + }); + + it("leaves the opted-in property off the default finder", () => { + post = g.model("post").findOne(); + expect(IsObject(post)).toBeTrue(); + expect(StructKeyExists(post, "titleAlias")).toBeFalse(); + }); + + it("throws Wheels.CalculatedPropertyNotFound for an unknown name in development/testing", () => { + expect(() => { + g.model("post").$selectClause( + select = "", + include = "", + returnAs = "query", + includeCalculated = "thisDoesNotExist" + ); + }).toThrow(type = "Wheels.CalculatedPropertyNotFound"); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-0-0/basics/models-and-the-orm.mdx b/web/sites/guides/src/content/docs/v4-0-0/basics/models-and-the-orm.mdx index bc5b25a530..e897ce39e0 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/basics/models-and-the-orm.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/basics/models-and-the-orm.mdx @@ -65,6 +65,26 @@ component extends="Model" { Composite keys are also supported — pass a comma-separated list to `setPrimaryKey("tenantId,entryId")`. Most apps never need them. +## Calculated SQL properties + +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`: + +```cfm +component extends="Model" { + function config() { + property(name="fullName", sql="firstName || ' ' || lastName", select=false); + } +} + +// Additive — all base columns are still selected; fullName is merged on top +user = model("User").findOne(includeCalculated="fullName"); + +// Comma-list for multiple properties; composes with other finder arguments +users = model("User").active().findAll(include="role", includeCalculated="fullName", page=1, perPage=25); +``` + +Unknown names passed to `includeCalculated` throw `Wheels.CalculatedPropertyNotFound` in `development` and `testing`, and are silently ignored in `production`. + ## Finders 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.