From add1daaa45febd1136420785f21cc5feabeb1742 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 03:44:53 +0000 Subject: [PATCH 1/5] test(model): prove-red hardener M2-M8 model-layer gaps Failing specs for uniqueness soft-delete default, QueryBuilder raw where docs, create() class leak, GetTickCount nested keys, belongsTo inner orphans, hasChanged StructDelete, and open mass assignment. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../model/hardener/ModelHardenerM2M8Spec.cfc | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc diff --git a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc new file mode 100644 index 000000000..abba50874 --- /dev/null +++ b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc @@ -0,0 +1,186 @@ +/** + * Hardener SHOULDs M2–M8 (model layer). + * + * Directory-scoped so `wheels test --core --ci --filter=model.hardener` + * discovers this folder (a single-file directory= scope finds 0 bundles). + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("M2 validatesUniquenessOf includeSoftDeletes default", () => { + + it("does not treat a soft-deleted row as a taken value by default", () => { + transaction action="begin" { + var orgPost = g.model("post").findOne(); + var newPost = g.model("post").new(orgPost.properties()); + orgPost.delete(); + expect(newPost.valid()).toBeTrue(); + transaction action="rollback"; + } + }); + + it("still treats a soft-deleted row as taken when includeSoftDeletes is true", () => { + transaction action="begin" { + var orgPost = g.model("post").findOne(); + var newPost = g.model("post").new(orgPost.properties()); + orgPost.delete(); + newPost.validatesUniquenessOf(properties = "title", includeSoftDeletes = true); + expect(newPost.valid()).toBeFalse(); + transaction action="rollback"; + } + }); + + }); + + describe("M3 QueryBuilder single-arg where is raw SQL", () => { + + it("does not advertise the builder as universally injection-safe", () => { + var src = FileRead(ExpandPath("/wheels/model/query/QueryBuilder.cfc")); + expect(FindNoCase("preventing SQL injection", src)).toBe( + 0, + "Class comment must not claim every where() form prevents SQL injection." + ); + expect(FindNoCase("raw", src)).toBeGT(0); + }); + + it("interpolates a single-argument where() clause verbatim", () => { + var modelRef = g.model("author"); + var qb = new wheels.model.query.QueryBuilder(modelReference = modelRef); + qb.where("lastName = 'x' OR 1=1"); + expect(qb.$buildFinderArgs().where).toBe("lastName = 'x' OR 1=1"); + }); + + }); + + describe("M4 create() must not pollute the shared class model", () => { + + afterEach(() => { + var authorClass = g.model("author"); + if (StructKeyExists(authorClass, "firstName") && authorClass.firstName == "ClassLeakXYZ") { + StructDelete(authorClass, "firstName"); + } + }); + + it("does not write mass-assigned properties onto the class model", () => { + var authorClass = g.model("author"); + StructDelete(authorClass, "firstName"); + transaction action="begin" { + authorClass.create(firstName = "ClassLeakXYZ", lastName = "Probe"); + transaction action="rollback"; + } + var leaked = StructKeyExists(authorClass, "firstName") && authorClass.firstName == "ClassLeakXYZ"; + expect(leaked).toBeFalse(); + }); + + }); + + describe("M5 hasMany nested keys must not use GetTickCount", () => { + + it("does not stamp an out-of-window numeric key as the child primary key", () => { + var tick = Val(Right(GetTickCount(), 12)); + var currentWindow = Ceiling(tick / 900000000); + var candidateA = 2700000000; + var candidateB = 4500000000; + var staleKey = (Ceiling(candidateA / 900000000) == currentWindow) ? candidateB : candidateA; + var nested = {}; + nested[staleKey] = {filename = "m5.jpg", DESCRIPTION1 = "m5"}; + var gallery = g.model("gallery").new( + title = "M5 Gallery", + description = "nested key probe", + userId = 1 + ); + gallery.$setCollectionAssociationProperty( + property = "photos", + value = nested, + association = gallery.$classData().associations.photos + ); + expect(ArrayLen(gallery.photos)).toBe(1); + expect(gallery.photos[1].isNew()).toBeTrue(); + if (StructKeyExists(gallery.photos[1], "id") && !IsNull(gallery.photos[1].id) && Len(gallery.photos[1].id)) { + expect(ToString(gallery.photos[1].id)).notToBe(ToString(staleKey)); + } + }); + + it("treats an explicit new- marker as a new child rather than a primary key", () => { + var gallery = g.model("gallery").new( + title = "M5 New Marker", + description = "nested key probe", + userId = 1 + ); + gallery.$setCollectionAssociationProperty( + property = "photos", + value = {"new-1": {filename = "m5-new.jpg", DESCRIPTION1 = "m5-new"}}, + association = gallery.$classData().associations.photos + ); + expect(ArrayLen(gallery.photos)).toBe(1); + expect(gallery.photos[1].isNew()).toBeTrue(); + if (StructKeyExists(gallery.photos[1], "id") && !IsNull(gallery.photos[1].id) && Len(gallery.photos[1].id)) { + expect(ToString(gallery.photos[1].id)).notToBe("new-1"); + } + }); + + }); + + describe("M6 belongsTo include must keep orphan parents", () => { + + it("keeps parent rows that have no associated record", () => { + transaction action="begin" { + var post = g.model("post").findOne(); + g.model("post").updateByKey( + key = post.id, + authorId = "", + validate = false, + transaction = "none" + ); + var allCount = g.model("post").count(); + var included = g.model("post").findAll(include = "author"); + expect(included.recordcount).toBe(allCount); + transaction action="rollback"; + } + }); + + }); + + describe("M7 hasChanged detects StructDelete of a persisted property", () => { + + it("returns true after StructDelete of a persisted property", () => { + var author = g.model("author").findOne(); + StructDelete(author, "firstName"); + expect(author.hasChanged("firstName")).toBeTrue(); + expect(author.hasChanged()).toBeTrue(); + }); + + it("still returns false for a property that was never present", () => { + var author = g.model("author").findOne(); + expect(author.hasChanged("somethingThatDoesNotExist")).toBeFalse(); + }); + + }); + + describe("M8 mass assignment default and strict option", () => { + + afterEach(() => { + application.wheels.massAssignmentStrict = false; + }); + + it("is open by default when neither accessible nor protected is configured", () => { + var author = g.model("author").new(properties = {firstName = "OpenDefault", lastName = "Probe"}); + expect(author.firstName).toBe("OpenDefault"); + expect(author.lastName).toBe("Probe"); + }); + + it("massAssignmentStrict leaves unlisted properties unassigned when neither list is configured", () => { + application.wheels.massAssignmentStrict = true; + var author = g.model("author").new(properties = {firstName = "StrictBlocked", lastName = "Probe"}); + var assigned = StructKeyExists(author, "firstName") && author.firstName == "StrictBlocked"; + expect(assigned).toBeFalse(); + }); + + }); + + } + +} From acfdf85cad9f53663bd5013780b43bdcea15c040 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 03:47:39 +0000 Subject: [PATCH 2/5] fix(model): harden uniqueness, create leak, nested keys, joins Change validatesUniquenessOf and belongsTo defaults toward the fail-safe forms, keep create() mass-assign on the instance, replace the GetTickCount nested-key heuristic, detect StructDelete in hasChanged, document raw query-builder where(), and add an opt-in massAssignmentStrict guard. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- CLAUDE.md | 2 +- changelog.d/model-hardener-m2-m8.added.md | 1 + changelog.d/model-hardener-m2-m8.changed.md | 3 + changelog.d/model-hardener-m2-m8.fixed.md | 3 + vendor/wheels/events/init/functions.cfm | 5 +- vendor/wheels/events/init/security.cfm | 5 ++ vendor/wheels/global/settings.cfm | 2 +- vendor/wheels/model/create.cfc | 3 +- vendor/wheels/model/nestedproperties.cfc | 72 ++++++++++++------- vendor/wheels/model/properties.cfc | 15 +++- vendor/wheels/model/query/QueryBuilder.cfc | 9 ++- vendor/wheels/model/validations.cfc | 4 +- .../reference/model/accessibleproperties.txt | 4 ++ .../public/docs/reference/model/belongsto.txt | 5 +- .../reference/model/protectedproperties.txt | 3 + .../reference/model/validatesuniquenessof.txt | 4 +- vendor/wheels/tests/specs/model/crudSpec.cfc | 18 ++--- .../tests/specs/model/queryBuilderSpec.cfc | 2 +- .../tests/specs/model/validationsSpec.cfc | 4 +- .../v4-0-0/model-configuration/belongsto.md | 2 +- .../validatesuniquenessof.md | 2 +- .../basics/query-builder-and-scopes.mdx | 2 +- .../docs/v4-0-0/upgrading/whats-new-in-4.mdx | 2 +- 23 files changed, 113 insertions(+), 59 deletions(-) create mode 100644 changelog.d/model-hardener-m2-m8.added.md create mode 100644 changelog.d/model-hardener-m2-m8.changed.md create mode 100644 changelog.d/model-hardener-m2-m8.fixed.md diff --git a/CLAUDE.md b/CLAUDE.md index f8f5c1f67..3c9b985a3 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -412,7 +412,7 @@ model("User").byRole("admin").findAll(page=1, perPage=25); user.isDraft(); // true/false model("User").draft().findAll(); -// Chainable query builder (injection-safe; values auto-quoted) +// Chainable query builder (2-/3-arg where is injection-safe; 1-arg is raw SQL) model("User") .where("status", "active") .where("age", ">", 18) diff --git a/changelog.d/model-hardener-m2-m8.added.md b/changelog.d/model-hardener-m2-m8.added.md new file mode 100644 index 000000000..42e988e26 --- /dev/null +++ b/changelog.d/model-hardener-m2-m8.added.md @@ -0,0 +1 @@ +- `set(massAssignmentStrict=true)` fail-closes mass assignment when a model has neither `accessibleProperties()` nor `protectedProperties()`; the default remains open for compatibility diff --git a/changelog.d/model-hardener-m2-m8.changed.md b/changelog.d/model-hardener-m2-m8.changed.md new file mode 100644 index 000000000..7d9a3496c --- /dev/null +++ b/changelog.d/model-hardener-m2-m8.changed.md @@ -0,0 +1,3 @@ +- `validatesUniquenessOf` now defaults `includeSoftDeletes` to `false`, so a soft-deleted value can be reused unless the caller opts in (`includeSoftDeletes=true`) +- `belongsTo` now defaults `joinType` to `outer` (`LEFT OUTER JOIN`), so `include` keeps parent rows that have no associated record; pass `joinType="inner"` to require a match +- Query builder docs now state that single-argument `where()` / `orWhere()` is a raw-SQL passthrough; the 2- and 3-argument forms remain the parameterized contract diff --git a/changelog.d/model-hardener-m2-m8.fixed.md b/changelog.d/model-hardener-m2-m8.fixed.md new file mode 100644 index 000000000..04d2bed9d --- /dev/null +++ b/changelog.d/model-hardener-m2-m8.fixed.md @@ -0,0 +1,3 @@ +- `create()` no longer mass-assigns properties onto the shared class model before instantiating the record +- Nested `hasMany` collection keys no longer use a `GetTickCount` window heuristic to decide "new" vs existing; form identities use a `new-` prefix (or `_new`), and a failed `findByKey` no longer stamps the key as the child primary key +- `hasChanged()` now treats `StructDelete` of a persisted property as a change diff --git a/vendor/wheels/events/init/functions.cfm b/vendor/wheels/events/init/functions.cfm index d95b52709..f7e4e1cdb 100644 --- a/vendor/wheels/events/init/functions.cfm +++ b/vendor/wheels/events/init/functions.cfm @@ -3,7 +3,7 @@ application.$wheels.functions = {}; application.$wheels.functions.autoLink = {link = "all", encode = true}; application.$wheels.functions.average = {distinct = false, parameterize = true, ifNull = ""}; - application.$wheels.functions.belongsTo = {joinType = "inner"}; + application.$wheels.functions.belongsTo = {joinType = "outer"}; application.$wheels.functions.buttonTo = { onlyPath = true, host = "", @@ -749,7 +749,8 @@ application.$wheels.functions.validatesPresenceOf = {message = "[property] can't be empty"}; application.$wheels.functions.validatesUniquenessOf = { message = "[property] has already been taken", - allowBlank = false + allowBlank = false, + includeSoftDeletes = false }; application.$wheels.functions.verifies = {handler = ""}; application.$wheels.functions.wordTruncate = {length = 5, truncateString = "..."}; diff --git a/vendor/wheels/events/init/security.cfm b/vendor/wheels/events/init/security.cfm index b26cae6bc..35955d17f 100644 --- a/vendor/wheels/events/init/security.cfm +++ b/vendor/wheels/events/init/security.cfm @@ -62,4 +62,9 @@ // reload rate-limit keying. Leave false unless the app sits behind a trusted reverse proxy // that overwrites — never appends to — these headers. application.$wheels.trustProxyHeaders = false; + + // Mass assignment is open unless accessibleProperties() or + // protectedProperties() is configured. Set true to fail closed when + // neither list exists — a breaking opt-in, not the framework default. + application.$wheels.massAssignmentStrict = false; diff --git a/vendor/wheels/global/settings.cfm b/vendor/wheels/global/settings.cfm index 5854cfbcf..d95d1340e 100644 --- a/vendor/wheels/global/settings.cfm +++ b/vendor/wheels/global/settings.cfm @@ -87,7 +87,7 @@ && StructKeyExists(request.wheels.tenant, "config") && StructKeyExists(request.wheels.tenant.config, arguments.name) && !ListFindNoCase( - "encryptionAlgorithm,encryptionSecretKey,encryptionEncoding,CSRFProtection,csrfStore,reloadPassword,obfuscateUrls", + "encryptionAlgorithm,encryptionSecretKey,encryptionEncoding,CSRFProtection,csrfStore,reloadPassword,obfuscateUrls,massAssignmentStrict", arguments.name ) ) { diff --git a/vendor/wheels/model/create.cfc b/vendor/wheels/model/create.cfc index 898b640ff..96c1ef5f7 100644 --- a/vendor/wheels/model/create.cfc +++ b/vendor/wheels/model/create.cfc @@ -27,7 +27,8 @@ component { $args(name = "create", args = arguments); $setProperties( argumentCollection = arguments, - filterList = "properties,parameterize,reload,validate,transaction,callbacks" + filterList = "properties,parameterize,reload,validate,transaction,callbacks", + setOnModel = false ); local.rv = new (argumentCollection = arguments); local.rv.save( diff --git a/vendor/wheels/model/nestedproperties.cfc b/vendor/wheels/model/nestedproperties.cfc index e79bd42e1..3650df309 100644 --- a/vendor/wheels/model/nestedproperties.cfc +++ b/vendor/wheels/model/nestedproperties.cfc @@ -194,38 +194,26 @@ component { } if (IsStruct(arguments.value)) { for (local.item in arguments.value) { - // Check to see if the id is a tickcount, if so the object is new. - if (IsNumeric(local.item) && Ceiling(Right(GetTickCount(), 12) / 900000000) == Ceiling(local.item / 900000000)) { - ArrayAppend( - this[arguments.property], - $getAssociationObject( - property = arguments.property, - value = arguments.value[local.item], - association = arguments.association, - delete = arguments.delete - ) - ); - $updateCollectionObject(property = arguments.property, value = arguments.value[local.item]); - } else { - // Get our primary keys. + if (!$isNewNestedCollectionKey(collectionKey = local.item, value = arguments.value[local.item])) { + // Existing-row key: copy the struct key into the primary key fields. local.keys = local.model.primaryKey(); local.itemArray = ListToArray(local.item, ",", true); local.iEnd = ListLen(local.keys); for (local.i = 1; local.i <= local.iEnd; local.i++) { arguments.value[local.item][ListGetAt(local.keys, local.i)] = local.itemArray[local.i]; } - - ArrayAppend( - this[arguments.property], - $getAssociationObject( - property = arguments.property, - value = arguments.value[local.item], - association = arguments.association, - delete = arguments.delete - ) - ); - $updateCollectionObject(property = arguments.property, value = arguments.value[local.item]); } + + ArrayAppend( + this[arguments.property], + $getAssociationObject( + property = arguments.property, + value = arguments.value[local.item], + association = arguments.association, + delete = arguments.delete + ) + ); + $updateCollectionObject(property = arguments.property, value = arguments.value[local.item]); } } else if (IsArray(arguments.value)) { for (local.i = 1; local.i <= ArrayLen(arguments.value); local.i++) { @@ -314,7 +302,7 @@ component { local.args.key = $createPrimaryKeyList(params = arguments.value, keys = local.model.primaryKey()); if (IsObject(arguments.value)) { local.object = arguments.value; - } else if (Len(local.args.key)) { + } else if (Len(local.args.key)) { local.object = local.model.findByKey(argumentCollection = local.args); } @@ -324,6 +312,8 @@ component { local.delete = true; } if (!IsObject(local.object) && !local.delete) { + // Key was not a persisted PK — do not stamp it onto a new child. + $clearNestedPrimaryKeys(value = arguments.value, keys = local.model.primaryKey()); StructDelete(local.args, "key"); return $invoke(componentReference = local.model, method = "new", invokeArgs = local.args); } else if (Len(local.args.key) && local.delete && arguments.association.nested.delete && arguments.delete) { @@ -334,6 +324,36 @@ component { return local.object; } + /** + * True when a hasMany nested-properties struct key identifies a new + * child: an explicit `_new` flag, a blank key, or a `new` / `new-*` / + * `new_*` token from `key($returnTickCountWhenNew=true)`. + */ + public boolean function $isNewNestedCollectionKey(required any collectionKey, required struct value) { + if (StructKeyExists(arguments.value, "_new") && IsBoolean(arguments.value["_new"]) && arguments.value["_new"]) { + return true; + } + local.keyString = ToString(arguments.collectionKey); + if (!Len(Trim(local.keyString))) { + return true; + } + if (ReFindNoCase("^new([-_].*)?$", local.keyString)) { + return true; + } + return false; + } + + /** + * Removes primary-key fields from a nested-properties value struct so a + * failed findByKey does not stamp a form identity onto a new child. + */ + public void function $clearNestedPrimaryKeys(required struct value, required string keys) { + local.iEnd = ListLen(arguments.keys); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + StructDelete(arguments.value, ListGetAt(arguments.keys, local.i)); + } + } + /** * Internal function. */ diff --git a/vendor/wheels/model/properties.cfc b/vendor/wheels/model/properties.cfc index ce850c469..48b0c339b 100644 --- a/vendor/wheels/model/properties.cfc +++ b/vendor/wheels/model/properties.cfc @@ -217,7 +217,7 @@ component { } } if (!Len(local.rv) && arguments.$returnTickCountWhenNew) { - local.rv = variables.wheels.tickCountId; + local.rv = "new-" & variables.wheels.tickCountId; } /* To fix the bug below: @@ -398,6 +398,9 @@ component { return true; } } + } else if (StructKeyExists(variables.$persistedProperties, local.key)) { + // Persisted property was removed from the instance (e.g. StructDelete). + return true; } } // if we get here, it means that all of the properties that were checked had a value in @@ -499,20 +502,26 @@ component { } // loop through the properties and see if they can be set based off of the accessible properties lists + local.hasWhiteList = StructKeyExists(variables.wheels.class.accessibleProperties, "whiteList"); + local.hasBlackList = StructKeyExists(variables.wheels.class.accessibleProperties, "blackList"); + local.strictOpen = arguments.$useFilterLists && !local.hasWhiteList && !local.hasBlackList && $get("massAssignmentStrict"); for (local.key in arguments.properties) { // required to ignore null keys if (StructKeyExists(arguments.properties, local.key)) { local.accessible = true; + if (local.strictOpen) { + local.accessible = false; + } if ( arguments.$useFilterLists && - StructKeyExists(variables.wheels.class.accessibleProperties, "whiteList") + local.hasWhiteList && !StructKeyExists(variables.wheels.class.accessibleProperties.whiteList, local.key) ) { local.accessible = false; } if ( arguments.$useFilterLists - && StructKeyExists(variables.wheels.class.accessibleProperties, "blackList") + && local.hasBlackList && StructKeyExists(variables.wheels.class.accessibleProperties.blackList, local.key) ) { local.accessible = false; diff --git a/vendor/wheels/model/query/QueryBuilder.cfc b/vendor/wheels/model/query/QueryBuilder.cfc index 1c406420c..3f5c43f4e 100644 --- a/vendor/wheels/model/query/QueryBuilder.cfc +++ b/vendor/wheels/model/query/QueryBuilder.cfc @@ -1,5 +1,5 @@ /** - * A chainable, injection-safe query builder for Wheels models. + * A chainable query builder for Wheels models. * Provides a fluent API alternative to the traditional `findAll(where="...")` string approach. * * Usage: @@ -10,8 +10,11 @@ * .limit(25) * .get(); * - * All values are safely quoted using the model's database adapter, preventing SQL injection. - * The builder ultimately delegates to the model's standard finder methods (findAll, findOne, etc.). + * The 2- and 3-argument `where()` / `orWhere()` forms quote values through the + * model's database adapter. The single-argument form is a raw-SQL passthrough + * for trusted clauses only — it is not parameterized. Prefer the 2-/3-arg forms + * for user input. The builder ultimately delegates to the model's standard + * finder methods (findAll, findOne, etc.). */ component output="false" { diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index 054af72a0..514daaf53 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -310,7 +310,7 @@ component { string scope = "", string condition = "", string unless = "", - boolean includeSoftDeletes = "true" + boolean includeSoftDeletes = "false" ) { $args(name = "validatesUniquenessOf", args = arguments); arguments.scope = $listClean(arguments.scope); @@ -728,7 +728,7 @@ component { required string message, string scope = "", struct properties = "#this.properties()#", - boolean includeSoftDeletes = "true" + boolean includeSoftDeletes = "false" ) { if (!IsBoolean(variables.wheels.class.tableName) || variables.wheels.class.tableName) { local.where = []; diff --git a/vendor/wheels/public/docs/reference/model/accessibleproperties.txt b/vendor/wheels/public/docs/reference/model/accessibleproperties.txt index 6569dd7da..e8b8f599d 100644 --- a/vendor/wheels/public/docs/reference/model/accessibleproperties.txt +++ b/vendor/wheels/public/docs/reference/model/accessibleproperties.txt @@ -1,3 +1,7 @@ +// Mass assignment is open by default: with neither accessibleProperties() nor +// protectedProperties(), every property can be set via new() / create() / update(). +// set(massAssignmentStrict=true) fail-closes that case (opt-in; not the default). + // 1. Allow only `isActive` to be set through mass assignment (e.g. `updateAll()`, `new()`, `update()`). config() { accessibleProperties("isActive"); diff --git a/vendor/wheels/public/docs/reference/model/belongsto.txt b/vendor/wheels/public/docs/reference/model/belongsto.txt index d10a1c7be..62fc6f441 100644 --- a/vendor/wheels/public/docs/reference/model/belongsto.txt +++ b/vendor/wheels/public/docs/reference/model/belongsto.txt @@ -5,8 +5,9 @@ belongsTo("author"); // 2. Override naming conventions by specifying `modelName` and `foreignKey` explicitly. belongsTo(name="bookWriter", modelName="author", foreignKey="authorId"); -// 3. Use a LEFT OUTER JOIN instead of the default INNER JOIN when including this association. -belongsTo(name="category", joinType="outer"); +// 3. Use an INNER JOIN when this model should always have a matching parent +// (the default is LEFT OUTER JOIN, which keeps rows with no associated record). +belongsTo(name="category", joinType="inner"); // 4. Declare a polymorphic belongsTo association (e.g. a Comment that can belong to a Post or a Photo). // Wheels will look for `commentableId` and `commentableType` columns on the comments table. diff --git a/vendor/wheels/public/docs/reference/model/protectedproperties.txt b/vendor/wheels/public/docs/reference/model/protectedproperties.txt index 419cc3a80..2e8cae94e 100644 --- a/vendor/wheels/public/docs/reference/model/protectedproperties.txt +++ b/vendor/wheels/public/docs/reference/model/protectedproperties.txt @@ -1,3 +1,6 @@ +// Without accessibleProperties() or protectedProperties(), mass assignment is open. +// set(massAssignmentStrict=true) fail-closes that case (opt-in; not the default). + // 1. Protect a comma-delimited list of properties from mass assignment in `models/User.cfc`. // `firstName` and `lastName` cannot be changed via `updateAll()`, `new()`, `update()`, etc. function config() { diff --git a/vendor/wheels/public/docs/reference/model/validatesuniquenessof.txt b/vendor/wheels/public/docs/reference/model/validatesuniquenessof.txt index c27ecb788..aa1075c32 100644 --- a/vendor/wheels/public/docs/reference/model/validatesuniquenessof.txt +++ b/vendor/wheels/public/docs/reference/model/validatesuniquenessof.txt @@ -16,5 +16,5 @@ validatesUniquenessOf(property="slug", when="onCreate"); // 6. Run the check only when a condition is true validatesUniquenessOf(property="referralCode", condition="this.isAffiliate()"); -// 7. Exclude soft-deleted records from the uniqueness check so a previously-deleted value can be reused -validatesUniquenessOf(property="username", includeSoftDeletes=false); +// 7. Include soft-deleted records in the uniqueness check so a previously-deleted value stays reserved +validatesUniquenessOf(property="username", includeSoftDeletes=true); diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index 337adb7de..7bd66131a 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -101,11 +101,11 @@ component extends="wheels.WheelsTest" { StructDelete(author, "firstName") result = author.hasChanged() - expect(result).toBeFalse() + expect(result).toBeTrue() result = author.hasChanged("firstName") - expect(result).toBeFalse() + expect(result).toBeTrue() result = author.hasChanged("somethingThatDoesNotExist") @@ -1268,12 +1268,12 @@ component extends="wheels.WheelsTest" { // emit FLAT sibling joins so the root FROM table stays in scope for every // ON condition. Wheels 3 over-fired the issue #449 parenthesized grouping // here, scoping the root out and triggering MySQL "Unknown column ... in - // 'on clause'". `author.user` is a belongsTo (inner) and `author.posts` / + // 'on clause'". `author.user` is a belongsTo (outer) and `author.posts` / // `user.galleries` are hasMany (outer) — exactly the reported shape. it("emits flat joins for a belongsTo-chain nested include (issue ##3245)", () => { actual = g.model("author").$fromClause(include = "posts,user(galleries)") - // No join here qualifies for grouping: `user` is INNER but sits at the root + // No join here qualifies for grouping: `user` is OUTER at the root // (nothing encloses it, and its ON references the root `authors`), and // `galleries` is OUTER. Every join stays at the top level, so the root table // remains in scope for every ON condition. @@ -1281,7 +1281,7 @@ component extends="wheels.WheelsTest" { expect(actual).toBe( "FROM #qi('c_o_r_e_authors')#" & " LEFT OUTER JOIN #qi('c_o_r_e_posts')# ON #qi('c_o_r_e_authors')#.#qi('id')# = #qi('c_o_r_e_posts')#.#qi('authorid')# AND #qi('c_o_r_e_posts')#.#qi('deletedat')# IS NULL" - & " INNER JOIN #qi('c_o_r_e_users')# ON #qi('c_o_r_e_authors')#.#qi('firstname')# = #qi('c_o_r_e_users')#.#qi('firstname')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_users')# ON #qi('c_o_r_e_authors')#.#qi('firstname')# = #qi('c_o_r_e_users')#.#qi('firstname')#" & " LEFT OUTER JOIN #qi('c_o_r_e_galleries')# ON #qi('c_o_r_e_users')#.#qi('id')# = #qi('c_o_r_e_galleries')#.#qi('userid')#" ) }) @@ -1320,17 +1320,17 @@ component extends="wheels.WheelsTest" { }) // Second shape of issue #3334, and a residual case of issue #3245 that the - // #3245 gate does not cover: a ROOT-level inner join (`Post.author` is a - // belongsTo) alongside a nested group. Its ON clause references the root + // #3245 gate does not cover: a ROOT-level belongsTo join (`Post.author`) + // alongside a nested group. Its ON clause references the root // `posts` table, so pulling it inside the classifications parentheses scopes // the root out — the same "unknown column in on clause" failure #3245 fixed // for the flat branch. A root-level join has no enclosing group; it stays flat. - it("keeps a root-level inner join out of the nested group (issue ##3334)", () => { + it("keeps a root-level belongsTo join out of the nested group (issue ##3334)", () => { actual = g.model("post").$fromClause(include = "author,classifications(tag)") expect(actual).toBe( "FROM #qi('c_o_r_e_posts')#" - & " INNER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#" & " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" ) }) diff --git a/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc b/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc index 636063472..7a85d1014 100644 --- a/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc +++ b/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc @@ -17,7 +17,7 @@ component extends="wheels.WheelsTest" { expect(result.recordcount).toBeGT(0); }) - it("passes through raw SQL strings (1-arg form)", () => { + it("passes through raw SQL strings (1-arg trusted-input form)", () => { var result = model("author").where("lastName = 'Djurner'").get(); expect(result.recordcount).toBe(1); }) diff --git a/vendor/wheels/tests/specs/model/validationsSpec.cfc b/vendor/wheels/tests/specs/model/validationsSpec.cfc index bb294acb4..b6cc99ac0 100644 --- a/vendor/wheels/tests/specs/model/validationsSpec.cfc +++ b/vendor/wheels/tests/specs/model/validationsSpec.cfc @@ -1019,14 +1019,14 @@ component extends="wheels.WheelsTest" { } }) - it("validatesUniquenessOf_takes_softdeletes_into_account", () => { + it("validatesUniquenessOf_excludes_softdeletes_by_default", () => { transaction action="begin" { org_post = g.model('post').findOne() properties = org_post.properties() new_post = g.model('post').new(properties) org_post.delete() valid = new_post.valid() - expect(valid).toBeFalse() + expect(valid).toBeTrue() transaction action="rollback"; } }) diff --git a/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md b/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md index 5606011cc..c95c6b06e 100644 --- a/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md +++ b/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md @@ -30,7 +30,7 @@ Use this association when this model contains a foreign key referencing another | `modelName` | `string` | no | — | Name of associated model (usually not needed if you follow Wheels conventions because the model name will be deduced from the `name` argument). | | `foreignKey` | `string` | no | — | Foreign key property name (usually not needed if you follow Wheels conventions since the foreign key name will be deduced from the `name` argument). | | `joinKey` | `string` | no | — | Column name to join to if not the primary key (usually not needed if you follow Wheels conventions since the join key will be the table's primary key/keys). | -| `joinType` | `string` | no | `inner` | Use to set the join type when joining associated tables. Possible values are `inner` (for `INNER JOIN`) and `outer` (for `LEFT OUTER JOIN`). | +| `joinType` | `string` | no | `outer` | Use to set the join type when joining associated tables. Possible values are `inner` (for `INNER JOIN`) and `outer` (for `LEFT OUTER JOIN`). Default `outer` keeps parent rows that have no associated record. | | `polymorphic` | `boolean` | no | `false` | | diff --git a/web/sites/api/src/content/docs/v4-0-0/model-configuration/validatesuniquenessof.md b/web/sites/api/src/content/docs/v4-0-0/model-configuration/validatesuniquenessof.md index 6683855bb..133a27db5 100644 --- a/web/sites/api/src/content/docs/v4-0-0/model-configuration/validatesuniquenessof.md +++ b/web/sites/api/src/content/docs/v4-0-0/model-configuration/validatesuniquenessof.md @@ -35,7 +35,7 @@ When the record is updated, the same check is made but disregarding the record i | `scope` | `string` | no | — | One or more properties by which to limit the scope of the uniqueness constraint. | | `condition` | `string` | no | — | String expression to be evaluated that decides if validation will be run (if the expression returns `true` validation will run). | | `unless` | `string` | no | — | String expression to be evaluated that decides if validation will be run (if the expression returns `false` validation will run). | -| `includeSoftDeletes` | `boolean` | no | `true` | Set to `true` to include soft-deleted records in the queries that this method runs. | +| `includeSoftDeletes` | `boolean` | no | `false` | Set to `true` to include soft-deleted records in the uniqueness check. The default excludes them so a previously deleted value can be reused. | diff --git a/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx b/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx index 221e4f0ab..2f54f7f43 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx @@ -41,7 +41,7 @@ component extends="Controller" { } ``` -The two-argument form of `where` means equality. The three-argument form takes an operator in the middle — `>`, `<`, `>=`, `<=`, `!=`, `LIKE`, and so on. Stack multiple `.where(...)` calls and they AND together. +The two-argument form of `where` means equality. The three-argument form takes an operator in the middle — `>`, `<`, `>=`, `<=`, `!=`, `LIKE`, and so on. Stack multiple `.where(...)` calls and they AND together. A single-argument `where("status = 'published'")` is a raw-SQL passthrough for trusted clauses only — it is not quoted or parameterized. Use the 2- or 3-argument forms for values that came from the request. ### Builder methods diff --git a/web/sites/guides/src/content/docs/v4-0-0/upgrading/whats-new-in-4.mdx b/web/sites/guides/src/content/docs/v4-0-0/upgrading/whats-new-in-4.mdx index e2cda5db2..d3d8e6211 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/upgrading/whats-new-in-4.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/upgrading/whats-new-in-4.mdx @@ -14,7 +14,7 @@ You know Wheels. You have a 3.x app in production. This page is the ten-minute a **Built-in dependency injection.** Register services in `config/services.cfm`, resolve with `service()` or `inject()`, scope as transient, singleton, or request. This is the change that removed the WireBox dependency — the container is `vendor/wheels/Injector.cfc`, part of the framework. → [The Dependency Injection Container](/v4-0-0/core-concepts/dependency-injection/) -**Chainable query builder, scopes, and enums.** `model("User").where("status","active").whereNotNull("emailVerifiedAt").orderBy("name").get()` — injection-safe, values auto-quoted. Named scopes compose (`model("User").active().recent().findAll()`), and `enum()` generates checkers and scopes from a property definition. → [Query Builder and Scopes](/v4-0-0/basics/query-builder-and-scopes/) +**Chainable query builder, scopes, and enums.** `model("User").where("status","active").whereNotNull("emailVerifiedAt").orderBy("name").get()` — 2-/3-arg `where` is injection-safe (values auto-quoted); 1-arg `where` is raw SQL. Named scopes compose (`model("User").active().recent().findAll()`), and `enum()` generates checkers and scopes from a property definition. → [Query Builder and Scopes](/v4-0-0/basics/query-builder-and-scopes/) **Background jobs.** `app/jobs/` CFCs extending `wheels.Job`, with queues, delayed/scheduled enqueue, retries with exponential backoff, and a worker loop (`wheels jobs work`). The `wheels_jobs` table auto-creates on first use. → [Background Jobs](/v4-0-0/digging-deeper/background-jobs/) From 36322f0747a653ad5522243710ad28e291bef8a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 03:48:59 +0000 Subject: [PATCH 3/5] test(model): update include SQL pins for belongsTo outer default Nested belongsTo joins are now LEFT OUTER JOIN, so the #449/#3334 fixtures emit flat joins instead of parenthesized INNER groups. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/tests/specs/model/crudSpec.cfc | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index 7bd66131a..9c1ed0f46 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -1287,16 +1287,16 @@ component extends="wheels.WheelsTest" { }) // Regression for issue #449 (must NOT be undone by the #3245 fix): a genuine - // HABTM / `through` bridge nested include keeps the parenthesized grouping so - // the bridge's INNER join stays scoped to the OUTER-joined bridge table. - // Team.memberTeams is a hasMany (outer bridge); its nested `member` inner - // join references the bridge table, so the grouping is correct here. + // HABTM / `through` bridge nested include. Team.memberTeams is a hasMany + // (outer bridge); nested `member` is belongsTo (outer by default), so both + // joins stay flat and parent rows are preserved. it("preserves nested grouping for a HABTM/through bridge include (issue ##449)", () => { actual = g.model("team").$fromClause(include = "memberTeams(member)") expect(actual).toBe( "FROM #qi('c_o_r_e_teams')#" - & " LEFT OUTER JOIN (#qi('c_o_r_e_memberteams')# INNER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#) ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_memberteams')# ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#" ) }) @@ -1305,8 +1305,8 @@ component extends="wheels.WheelsTest" { // got the nested group's INNER join spliced into it — referencing a table the // query has not introduced yet (ORA-00904 / "unknown column in on clause"). // `Post.c_o_r_e_comments` and `Post.classifications` are both hasMany (outer); - // `Classification.tag` is a belongsTo (inner) whose ON clause references - // `classifications`, so it belongs to the classifications group and nowhere else. + // `Classification.tag` is a belongsTo (outer by default) whose ON clause + // references `classifications`. All three joins stay flat. it("scopes a nested inner join to its own parent, not to every outer join (issue ##3334)", () => { actual = g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)") @@ -1315,7 +1315,8 @@ component extends="wheels.WheelsTest" { expect(actual).toBe( "FROM #qi('c_o_r_e_posts')#" & " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#" - & " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" ) }) @@ -1331,7 +1332,8 @@ component extends="wheels.WheelsTest" { expect(actual).toBe( "FROM #qi('c_o_r_e_posts')#" & " LEFT OUTER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#" - & " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" ) }) @@ -1378,7 +1380,8 @@ component extends="wheels.WheelsTest" { // from the association tree, so include order only reorders the emitted joins. it("emits the same joins wherever the nested group sits in the include (issue ##3334)", () => { comments = " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#" - classifications = " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + classifications = " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" + & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" expect(g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)")).toBe( "FROM #qi('c_o_r_e_posts')#" & comments & classifications From 70c279d2001dde20fda494952e9b265e541744ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 03:56:40 +0000 Subject: [PATCH 4/5] fix(model): restore belongsTo inner default and struct afterFind Revert belongsTo include default joinType to inner. Opt-in joinType=outer still keeps orphan parents (hardener + restored #449/#3334 SQL pins). Invoke afterFind on each returnAs=struct row so callbacks no longer read columns off the class singleton. Co-authored-by: Peter Amiri Signed-off-by: Cursor Agent --- changelog.d/model-hardener-m2-m8.changed.md | 1 - changelog.d/model-hardener-m2-m8.fixed.md | 1 + vendor/wheels/events/init/functions.cfm | 2 +- vendor/wheels/model/serialize.cfc | 11 +++-- .../public/docs/reference/model/belongsto.txt | 5 +-- vendor/wheels/tests/specs/model/crudSpec.cfc | 37 ++++++++--------- .../model/hardener/ModelHardenerM2M8Spec.cfc | 40 ++++++++++++++++++- .../v4-0-0/model-configuration/belongsto.md | 2 +- 8 files changed, 68 insertions(+), 31 deletions(-) diff --git a/changelog.d/model-hardener-m2-m8.changed.md b/changelog.d/model-hardener-m2-m8.changed.md index 7d9a3496c..276a0a963 100644 --- a/changelog.d/model-hardener-m2-m8.changed.md +++ b/changelog.d/model-hardener-m2-m8.changed.md @@ -1,3 +1,2 @@ - `validatesUniquenessOf` now defaults `includeSoftDeletes` to `false`, so a soft-deleted value can be reused unless the caller opts in (`includeSoftDeletes=true`) -- `belongsTo` now defaults `joinType` to `outer` (`LEFT OUTER JOIN`), so `include` keeps parent rows that have no associated record; pass `joinType="inner"` to require a match - Query builder docs now state that single-argument `where()` / `orWhere()` is a raw-SQL passthrough; the 2- and 3-argument forms remain the parameterized contract diff --git a/changelog.d/model-hardener-m2-m8.fixed.md b/changelog.d/model-hardener-m2-m8.fixed.md index 04d2bed9d..84da23dcd 100644 --- a/changelog.d/model-hardener-m2-m8.fixed.md +++ b/changelog.d/model-hardener-m2-m8.fixed.md @@ -1,3 +1,4 @@ - `create()` no longer mass-assigns properties onto the shared class model before instantiating the record - Nested `hasMany` collection keys no longer use a `GetTickCount` window heuristic to decide "new" vs existing; form identities use a `new-` prefix (or `_new`), and a failed `findByKey` no longer stamps the key as the child primary key - `hasChanged()` now treats `StructDelete` of a persisted property as a change +- `findAll(returnAs="struct")` invokes `afterFind` against each row struct instead of the shared class model, so callbacks that read persisted columns no longer depend on `create()` leaking onto the class diff --git a/vendor/wheels/events/init/functions.cfm b/vendor/wheels/events/init/functions.cfm index f7e4e1cdb..aab632fcd 100644 --- a/vendor/wheels/events/init/functions.cfm +++ b/vendor/wheels/events/init/functions.cfm @@ -3,7 +3,7 @@ application.$wheels.functions = {}; application.$wheels.functions.autoLink = {link = "all", encode = true}; application.$wheels.functions.average = {distinct = false, parameterize = true, ifNull = ""}; - application.$wheels.functions.belongsTo = {joinType = "outer"}; + application.$wheels.functions.belongsTo = {joinType = "inner"}; application.$wheels.functions.buttonTo = { onlyPath = true, host = "", diff --git a/vendor/wheels/model/serialize.cfc b/vendor/wheels/model/serialize.cfc index 4d834cbd3..ae2903f8f 100644 --- a/vendor/wheels/model/serialize.cfc +++ b/vendor/wheels/model/serialize.cfc @@ -174,9 +174,14 @@ component { Called the afterFind hook, the hook adds the arguments defined in the hook to the object so get the properties using properties() function and then append the property struct to the individual record struct */ if (arguments.callbacks && structKeyExists(this, 'afterFindCallback') && arguments.returnas eq "struct") { - $callback("afterFind", arguments.callbacks); - local.objectProps = properties(); - structAppend(local.struct, local.objectProps); + // Invoke against the row struct, not the class model. The previous + // `$callback("afterFind")` + `properties()` path ran afterFind on + // `this` (the shared class) and only worked when create() had leaked + // column values onto that singleton. + local.afterFindResult = $invoke(method = "afterFindCallback", invokeArgs = local.struct); + if (StructKeyExists(local, "afterFindResult") && IsStruct(local.afterFindResult)) { + structAppend(local.struct, local.afterFindResult); + } } ArrayAppend(local.rv, local.struct); diff --git a/vendor/wheels/public/docs/reference/model/belongsto.txt b/vendor/wheels/public/docs/reference/model/belongsto.txt index 62fc6f441..d10a1c7be 100644 --- a/vendor/wheels/public/docs/reference/model/belongsto.txt +++ b/vendor/wheels/public/docs/reference/model/belongsto.txt @@ -5,9 +5,8 @@ belongsTo("author"); // 2. Override naming conventions by specifying `modelName` and `foreignKey` explicitly. belongsTo(name="bookWriter", modelName="author", foreignKey="authorId"); -// 3. Use an INNER JOIN when this model should always have a matching parent -// (the default is LEFT OUTER JOIN, which keeps rows with no associated record). -belongsTo(name="category", joinType="inner"); +// 3. Use a LEFT OUTER JOIN instead of the default INNER JOIN when including this association. +belongsTo(name="category", joinType="outer"); // 4. Declare a polymorphic belongsTo association (e.g. a Comment that can belong to a Post or a Photo). // Wheels will look for `commentableId` and `commentableType` columns on the comments table. diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index 9c1ed0f46..4c4ed229b 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -1268,12 +1268,12 @@ component extends="wheels.WheelsTest" { // emit FLAT sibling joins so the root FROM table stays in scope for every // ON condition. Wheels 3 over-fired the issue #449 parenthesized grouping // here, scoping the root out and triggering MySQL "Unknown column ... in - // 'on clause'". `author.user` is a belongsTo (outer) and `author.posts` / + // 'on clause'". `author.user` is a belongsTo (inner) and `author.posts` / // `user.galleries` are hasMany (outer) — exactly the reported shape. it("emits flat joins for a belongsTo-chain nested include (issue ##3245)", () => { actual = g.model("author").$fromClause(include = "posts,user(galleries)") - // No join here qualifies for grouping: `user` is OUTER at the root + // No join here qualifies for grouping: `user` is INNER but sits at the root // (nothing encloses it, and its ON references the root `authors`), and // `galleries` is OUTER. Every join stays at the top level, so the root table // remains in scope for every ON condition. @@ -1281,22 +1281,22 @@ component extends="wheels.WheelsTest" { expect(actual).toBe( "FROM #qi('c_o_r_e_authors')#" & " LEFT OUTER JOIN #qi('c_o_r_e_posts')# ON #qi('c_o_r_e_authors')#.#qi('id')# = #qi('c_o_r_e_posts')#.#qi('authorid')# AND #qi('c_o_r_e_posts')#.#qi('deletedat')# IS NULL" - & " LEFT OUTER JOIN #qi('c_o_r_e_users')# ON #qi('c_o_r_e_authors')#.#qi('firstname')# = #qi('c_o_r_e_users')#.#qi('firstname')#" + & " INNER JOIN #qi('c_o_r_e_users')# ON #qi('c_o_r_e_authors')#.#qi('firstname')# = #qi('c_o_r_e_users')#.#qi('firstname')#" & " LEFT OUTER JOIN #qi('c_o_r_e_galleries')# ON #qi('c_o_r_e_users')#.#qi('id')# = #qi('c_o_r_e_galleries')#.#qi('userid')#" ) }) // Regression for issue #449 (must NOT be undone by the #3245 fix): a genuine - // HABTM / `through` bridge nested include. Team.memberTeams is a hasMany - // (outer bridge); nested `member` is belongsTo (outer by default), so both - // joins stay flat and parent rows are preserved. + // HABTM / `through` bridge nested include keeps the parenthesized grouping so + // the bridge's INNER join stays scoped to the OUTER-joined bridge table. + // Team.memberTeams is a hasMany (outer bridge); its nested `member` inner + // join references the bridge table, so the grouping is correct here. it("preserves nested grouping for a HABTM/through bridge include (issue ##449)", () => { actual = g.model("team").$fromClause(include = "memberTeams(member)") expect(actual).toBe( "FROM #qi('c_o_r_e_teams')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_memberteams')# ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#" + & " LEFT OUTER JOIN (#qi('c_o_r_e_memberteams')# INNER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#) ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#" ) }) @@ -1305,8 +1305,8 @@ component extends="wheels.WheelsTest" { // got the nested group's INNER join spliced into it — referencing a table the // query has not introduced yet (ORA-00904 / "unknown column in on clause"). // `Post.c_o_r_e_comments` and `Post.classifications` are both hasMany (outer); - // `Classification.tag` is a belongsTo (outer by default) whose ON clause - // references `classifications`. All three joins stay flat. + // `Classification.tag` is a belongsTo (inner) whose ON clause references + // `classifications`, so it belongs to the classifications group and nowhere else. it("scopes a nested inner join to its own parent, not to every outer join (issue ##3334)", () => { actual = g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)") @@ -1315,25 +1315,23 @@ component extends="wheels.WheelsTest" { expect(actual).toBe( "FROM #qi('c_o_r_e_posts')#" & " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" + & " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" ) }) // Second shape of issue #3334, and a residual case of issue #3245 that the - // #3245 gate does not cover: a ROOT-level belongsTo join (`Post.author`) - // alongside a nested group. Its ON clause references the root + // #3245 gate does not cover: a ROOT-level inner join (`Post.author` is a + // belongsTo) alongside a nested group. Its ON clause references the root // `posts` table, so pulling it inside the classifications parentheses scopes // the root out — the same "unknown column in on clause" failure #3245 fixed // for the flat branch. A root-level join has no enclosing group; it stays flat. - it("keeps a root-level belongsTo join out of the nested group (issue ##3334)", () => { + it("keeps a root-level inner join out of the nested group (issue ##3334)", () => { actual = g.model("post").$fromClause(include = "author,classifications(tag)") expect(actual).toBe( "FROM #qi('c_o_r_e_posts')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" + & " INNER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#" + & " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" ) }) @@ -1380,8 +1378,7 @@ component extends="wheels.WheelsTest" { // from the association tree, so include order only reorders the emitted joins. it("emits the same joins wherever the nested group sits in the include (issue ##3334)", () => { comments = " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#" - classifications = " LEFT OUTER JOIN #qi('c_o_r_e_classifications')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" - & " LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#" + classifications = " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#" expect(g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)")).toBe( "FROM #qi('c_o_r_e_posts')#" & comments & classifications diff --git a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc index abba50874..08dfedb2a 100644 --- a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc +++ b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc @@ -124,9 +124,34 @@ component extends="wheels.WheelsTest" { }); - describe("M6 belongsTo include must keep orphan parents", () => { + describe("M6 belongsTo include default is inner; outer is opt-in", () => { - it("keeps parent rows that have no associated record", () => { + afterEach(() => { + var associations = g.model("post").$classData().associations; + associations.author.joinType = "inner"; + $resetJoinMemo("post", "author"); + }); + + it("drops parent rows that have no associated record by default", () => { + transaction action="begin" { + var post = g.model("post").findOne(); + g.model("post").updateByKey( + key = post.id, + authorId = "", + validate = false, + transaction = "none" + ); + var allCount = g.model("post").count(); + var included = g.model("post").findAll(include = "author"); + expect(included.recordcount).toBeLT(allCount); + transaction action="rollback"; + } + }); + + it("keeps orphan parents when the association opts in with joinType=outer", () => { + var associations = g.model("post").$classData().associations; + associations.author.joinType = "outer"; + $resetJoinMemo("post", "author"); transaction action="begin" { var post = g.model("post").findOne(); g.model("post").updateByKey( @@ -183,4 +208,15 @@ component extends="wheels.WheelsTest" { } + /** + * Clears $expandedAssociations join memo so a runtime joinType flip is honoured. + */ + private void function $resetJoinMemo(required string modelName, required string association) { + var associations = application.wo.model(arguments.modelName).$classData().associations; + if (StructKeyExists(associations, arguments.association)) { + StructDelete(associations[arguments.association], "join"); + StructDelete(associations[arguments.association], "joinVariants"); + } + } + } diff --git a/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md b/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md index c95c6b06e..5606011cc 100644 --- a/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md +++ b/web/sites/api/src/content/docs/v4-0-0/model-configuration/belongsto.md @@ -30,7 +30,7 @@ Use this association when this model contains a foreign key referencing another | `modelName` | `string` | no | — | Name of associated model (usually not needed if you follow Wheels conventions because the model name will be deduced from the `name` argument). | | `foreignKey` | `string` | no | — | Foreign key property name (usually not needed if you follow Wheels conventions since the foreign key name will be deduced from the `name` argument). | | `joinKey` | `string` | no | — | Column name to join to if not the primary key (usually not needed if you follow Wheels conventions since the join key will be the table's primary key/keys). | -| `joinType` | `string` | no | `outer` | Use to set the join type when joining associated tables. Possible values are `inner` (for `INNER JOIN`) and `outer` (for `LEFT OUTER JOIN`). Default `outer` keeps parent rows that have no associated record. | +| `joinType` | `string` | no | `inner` | Use to set the join type when joining associated tables. Possible values are `inner` (for `INNER JOIN`) and `outer` (for `LEFT OUTER JOIN`). | | `polymorphic` | `boolean` | no | `false` | | From 596f22e3c0d7e42ff29d510e6a6826fe0aa9ddec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 03:59:31 +0000 Subject: [PATCH 5/5] test(model): pin belongsTo default-inner and opt-in outer Prove default include drops orphans, and that a declared joinType=outer association keeps rows with no parent. Co-authored-by: Peter Amiri Signed-off-by: Cursor Agent --- .../model/hardener/ModelHardenerM2M8Spec.cfc | 42 +++++-------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc index 08dfedb2a..8fe2e6e80 100644 --- a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc +++ b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc @@ -126,10 +126,9 @@ component extends="wheels.WheelsTest" { describe("M6 belongsTo include default is inner; outer is opt-in", () => { - afterEach(() => { - var associations = g.model("post").$classData().associations; - associations.author.joinType = "inner"; - $resetJoinMemo("post", "author"); + it("registers belongsTo with joinType inner by default", () => { + expect(g.model("post").$classData().associations.author.joinType).toBe("inner"); + expect(g.model("post").$fromClause(include = "author")).toInclude("INNER JOIN"); }); it("drops parent rows that have no associated record by default", () => { @@ -141,6 +140,8 @@ component extends="wheels.WheelsTest" { validate = false, transaction = "none" ); + post.reload(); + expect(post.authorId).toBeEmpty(); var allCount = g.model("post").count(); var included = g.model("post").findAll(include = "author"); expect(included.recordcount).toBeLT(allCount); @@ -149,22 +150,12 @@ component extends="wheels.WheelsTest" { }); it("keeps orphan parents when the association opts in with joinType=outer", () => { - var associations = g.model("post").$classData().associations; - associations.author.joinType = "outer"; - $resetJoinMemo("post", "author"); - transaction action="begin" { - var post = g.model("post").findOne(); - g.model("post").updateByKey( - key = post.id, - authorId = "", - validate = false, - transaction = "none" - ); - var allCount = g.model("post").count(); - var included = g.model("post").findAll(include = "author"); - expect(included.recordcount).toBe(allCount); - transaction action="rollback"; - } + expect(g.model("tag").$classData().associations.parent.joinType).toBe("outer"); + expect(g.model("tag").$fromClause(include = "parent")).toInclude("LEFT OUTER JOIN"); + var allCount = g.model("tag").count(); + var included = g.model("tag").findAll(include = "parent"); + expect(allCount).toBeGT(0); + expect(included.recordcount).toBe(allCount); }); }); @@ -208,15 +199,4 @@ component extends="wheels.WheelsTest" { } - /** - * Clears $expandedAssociations join memo so a runtime joinType flip is honoured. - */ - private void function $resetJoinMemo(required string modelName, required string association) { - var associations = application.wo.model(arguments.modelName).$classData().associations; - if (StructKeyExists(associations, arguments.association)) { - StructDelete(associations[arguments.association], "join"); - StructDelete(associations[arguments.association], "joinVariants"); - } - } - }