feat(model): add includeCalculated to additively opt in select=false SQL properties#3254
Conversation
…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>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR adds an additive includeCalculated argument to findAll() / findOne() / findByKey() so a select=false calculated property can be opted back into a single finder by name without re-listing every column. The implementation is correct, cross-engine-safe, and backed by a focused BDD spec. Verdict: comment — no blocking issues; two minor, non-blocking observations below.
Correctness
The threading and additive merge are sound:
findByKeyonly adds the argument to its signature, but it forwards viaargumentCollection = argumentstofindOne->findAll(vendor/wheels/model/read.cfc:452,:511), soincludeCalculatedreaches$selectClausewithout an explicit pass-through. Correct.- Both
$createSQLFieldListcall sites infindAllthread the argument — the empty-result column-shell path (read.cfc:220) and the main$selectClausepath (read.cfc:254) — so the opted-in property survives even on an emptyreturnAs="query"result. includeCalculatedis part ofargumentswhen$hashedKeybuildslocal.queryShellKey(read.cfc:243), so anincludeCalculated="titleAlias"finder gets its own SQL-cache entry and will not collide with the default finder. Good — this is the kind of thing that silently breaks otherwise.- The dedup guard
if (!ListFindNoCase(arguments.list, local.calcName))(sql.cfc:534) correctly avoids a double-add when the property is alreadyselect=trueor was named inselect. - No injection surface: names are validated against declared
calculatedProperties(throw in dev/testing, silent skip in production), so only declared identifiers ever reach the field list.
Cross-engine
Clean. The new block in $createSQLFieldList (sql.cfc:510-538) is pure list/struct manipulation — classic numeric for loop, ListToArray, Trim, StructKeyExists, ListFindNoCase, ListAppend, Throw. No closures, no obj.map(), no Left(str,0), not inside a finally, no reserved-scope param names. get("environment") is the established dev-gate accessor used two lines up in the same file (sql.cfc:456). No engine-specific traps.
Tests
includeCalculatedSpec.cfc follows the WheelsTest BDD convention (g = application.wo, toInclude/notToInclude) and covers the additive SELECT, comma-list, populated-on-real-finder, absent-on-default, and the Wheels.CalculatedPropertyNotFound throw. Two minor gaps, neither blocking:
- No spec exercises
findByKey(includeCalculated=...)directly. The path is covered transitively throughfindOne, so this is a completeness nit rather than a coverage hole. - No assertion for the production no-op branch (unknown name silently ignored,
sql.cfc:531). Worth a line if aset(environment="production")toggle is feasible in the suite, but the dev-throw is the load-bearing path and it is tested.
Conventions
@includeCalculated docstrings are added to findAll, findByKey, and findOne with the [see:findAll] cross-reference idiom. Argument ordering is consistent across all three signatures.
One scope note (not a defect): validation only checks variables.wheels.class.calculatedProperties — the current model (sql.cfc:521). A calculated property declared on an include-ed association would throw CalculatedPropertyNotFound even though the later resolution loop iterates all classes. This matches the PR description ("the model's calculated properties") and is a reasonable v1 boundary; consider documenting the current-model-only limitation in the user guide when docs land via bot-update-docs.yml.
Commits / Docs
- Commit
a5b58ea—feat(model): ..., valid type/scope, header under 100 chars, carriesSigned-off-by:. Conforms to commitlint. - Changelog fragment
changelog.d/3252-include-calculated.added.mdwithaddedtype — correct fragment-file approach, no direct[Unreleased]edit. - Framework/AI/CLAUDE.md docs deferred to
bot-update-docs.ymlper the checklist.
Nice, well-scoped change.
… properties Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR adds an additive includeCalculated argument to findAll() / findOne() / findByKey() so a select=false calculated SQL property can be opted back into a single finder by name without re-listing every column. The core implementation in sql.cfc is correct, cross-engine safe, threaded cleanly through all three finders, and backed by a focused BDD spec against real select=false fixtures (titleAlias, createdAtAlias on the Post test model). The only issue I found is a broken Markdown fence in the internal snippet doc — a docs nit, not a blocking finding. Verdict: comment.
Docs
.ai/wheels/snippets/model-snippets.md:164— the new edit inserts a closing code fence mid-section, ejecting the pre-existing method-based calculated property example out of thecfmblock. The original section was a single fenced block; this PR raises the file fence count from 12 (balanced, pergit grep -con origin/develop) to 13 (unbalanced). As a result lines 166–177 (// Method-based calculated property…function isExpired()) now render as plain prose instead of code, and the trailing fence at line 178 becomes an orphan opening fence with no closer. Fix: drop the inserted closing fence (line 164) and the blank line after it so the method-based example stays inside the samecfmblock, restoring even fence parity. (The user-facing guidemodels-and-the-orm.mdxis unaffected — its fences stay balanced, 18→20.)
Correctness (verified, no findings)
- The appended bare calc name resolves correctly:
$createSQLFieldListmatches it viaStructKeyExists(local.classData.calculatedProperties, local.iItem)(vendor/wheels/model/sql.cfc:598) and emits the<sql> AS <name>column, confirmed by thetoInclude("AS titleAlias")assertions. - Additive guarantee holds — the
includeCalculatedblock (vendor/wheels/model/sql.cfc:513-538) runs after the default-population block (sql.cfc:495-508) and onlyListAppends, never replacesarguments.list. - Dedup guard
ListFindNoCase(arguments.list, local.calcName)(vendor/wheels/model/sql.cfc:534) prevents a double-add when aselect=truecalc property is also named. - New arg is optional with default
""on both$selectClauseand$createSQLFieldList, so existing callers are unaffected; both internal call sites inread.cfcare updated. findByKey->findOne->findAllall threadincludeCalculatedviaargumentCollection, so the doc-claimedfindByKeysupport is real.
Cross-engine (verified, no findings)
The new block is pure list/struct manipulation (ListToArray, Trim, Len, StructKeyExists, ListFindNoCase, ListAppend, Throw) — no member-function .map(), no obj["key"](), no closures, no Left(str,0), and the for loop is not inside a finally. get("environment") matches established prior art in the same file (vendor/wheels/model/sql.cfc:456). Gating the dev-only throw on development,testing (rather than development alone) is necessary and correct — the suite runs in testing, which is why the toThrow(type="Wheels.CalculatedPropertyNotFound") assertion fires.
Tests (verified, no findings)
includeCalculatedSpec.cfc extends wheels.WheelsTest, covers additive SELECT generation, comma-list opt-in, population on a live finder, absence on the default finder, and the unknown-name throw — both happy and error paths. Fixtures (vendor/wheels/tests/_assets/models/Post.cfc:9,11) genuinely declare the two properties select=false, so the additive claim is actually exercised.
Commits (verified, no findings)
Both commits conform to commitlint.config.js: feat(model): … and docs: … — valid types, subjects within length, not ALL-CAPS. Changelog fragment changelog.d/3252-include-calculated.added.md uses the fragment system with a valid added type rather than editing CHANGELOG.md directly.
Summary
Adds an
includeCalculatedargument tofindAll(),findOne(), andfindByKey()so a calculated SQL property declaredselect=falsecan be opted back into a single finder by name —model("User").findAll(includeCalculated="fullName"). Unlikeselect, it is additive: the named calculated properties are merged on top of the default column list inside$createSQLFieldListrather than replacing it, so the rest of the record is still returned. This closes the inverse of the existingproperty(name=..., sql=..., 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 throwWheels.CalculatedPropertyNotFoundin development/testing and are ignored in production, mirroring existing dev-only validation patterns.This follows the cross-framework research recommendation (additive, by-name, declared-once opt-in) verbatim, including the argument name
includeCalculated.Recommended path from research: #3252 (comment)
Related Issue
Fixes #3252
Type of Change
Feature Completeness Checklist
Signed-off-by:(git commit -s)vendor/wheels/tests/specs/model/includeCalculatedSpec.cfc(failing -> passing): additive SELECT generation, comma-list opt-in, populated property on a real finder, absence on the default finder, andWheels.CalculatedPropertyNotFoundon unknown namesbot-update-docs.ymlbot-update-docs.ymlbot-update-docs.ymlchangelog.d/3252-include-calculated.added.mdwheels.tests.specs.modellayer returns HTTP 200 (no failures or errors)Test Plan
// Post declares: property(name="titleAlias", sql="title", select=false) model("post").findOne(); // titleAlias absent (default) model("post").findOne(includeCalculated="titleAlias"); // titleAlias populated, base columns intact model("post").findOne(includeCalculated="nope"); // throws Wheels.CalculatedPropertyNotFound in dev/testingImplementation
vendor/wheels/model/sql.cfc--$selectClauseand$createSQLFieldListaccept a newincludeCalculatedargument; the names are validated against the model'scalculatedPropertiesand additively merged into the select list (pure list manipulation, no cross-engine traps).vendor/wheels/model/read.cfc--findAll/findOne/findByKeyaccept and threadincludeCalculatedthrough to the select-clause builder.