diff --git a/CLAUDE.md b/CLAUDE.md index f8f5c1f67b..3c9b985a3b 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 0000000000..42e988e263 --- /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 0000000000..276a0a963d --- /dev/null +++ b/changelog.d/model-hardener-m2-m8.changed.md @@ -0,0 +1,2 @@ +- `validatesUniquenessOf` now defaults `includeSoftDeletes` to `false`, so a soft-deleted value can be reused unless the caller opts in (`includeSoftDeletes=true`) +- 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 0000000000..84da23dcda --- /dev/null +++ b/changelog.d/model-hardener-m2-m8.fixed.md @@ -0,0 +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 d95b527092..aab632fcda 100644 --- a/vendor/wheels/events/init/functions.cfm +++ b/vendor/wheels/events/init/functions.cfm @@ -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 b26cae6bce..35955d17f8 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 5854cfbcf8..d95d1340ed 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 898b640ff2..96c1ef5f76 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 e79bd42e1a..3650df3092 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 ce850c4697..48b0c339b9 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 1c406420c6..3f5c43f4e7 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/serialize.cfc b/vendor/wheels/model/serialize.cfc index 4d834cbd3b..ae2903f8f6 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/model/validations.cfc b/vendor/wheels/model/validations.cfc index 054af72a0e..514daaf53b 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 6569dd7da8..e8b8f599d1 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/protectedproperties.txt b/vendor/wheels/public/docs/reference/model/protectedproperties.txt index 419cc3a809..2e8cae94ef 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 c27ecb788d..aa1075c325 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 337adb7def..4c4ed229b6 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") 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 0000000000..8fe2e6e806 --- /dev/null +++ b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc @@ -0,0 +1,202 @@ +/** + * 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 default is inner; outer is opt-in", () => { + + 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", () => { + transaction action="begin" { + var post = g.model("post").findOne(); + g.model("post").updateByKey( + key = post.id, + authorId = "", + 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); + transaction action="rollback"; + } + }); + + it("keeps orphan parents when the association opts in with joinType=outer", () => { + 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); + }); + + }); + + 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(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc b/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc index 6360634723..7a85d10144 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 bb294acb4f..b6cc99ac03 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/validatesuniquenessof.md b/web/sites/api/src/content/docs/v4-0-0/model-configuration/validatesuniquenessof.md index 6683855bbc..133a27db5f 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 221e4f0aba..2f54f7f435 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 e2cda5db20..d3d8e62114 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/)