diff --git a/changelog.d/3350-uniqueness-scope-guard.fixed.md b/changelog.d/3350-uniqueness-scope-guard.fixed.md new file mode 100644 index 000000000..a6e6cb380 --- /dev/null +++ b/changelog.d/3350-uniqueness-scope-guard.fixed.md @@ -0,0 +1 @@ +- `validatesUniquenessOf(property="x", scope="y")` now returns a validation result instead of throwing `Component [Model] has no accessible Member with name [y]` when the scope property was never assigned. Building the uniqueness `WHERE` clause dereferenced every scope property without an existence guard, and a scope property is easy to leave absent rather than empty: `$setDefaultValues()` only seeds properties with an explicit `property()` mapping, so a column with a database-level default but no mapping is missing from a `new()`-ed object entirely. An absent scope property is now treated as blank, matching what a present-but-empty one has always done — including the existing conversion of an empty numeric scope to `IS NULL`. The `property(name="", defaultValue="")` workaround is no longer needed (#3350) diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index cfd0ff5df..b1dffdeba 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -783,8 +783,18 @@ component { * and converting blank numeric properties to IS NULL. */ public string function $buildWhereClausePart(required string property) { + // A property named in `scope=` may be absent rather than empty: `$setDefaultValues()` + // only seeds properties with an explicit `property()` mapping, so a column that has a + // database-level default but no mapping never appears on a `new()`-ed object. Reading + // it unguarded threw "has no accessible Member" out of a validation, so an absent + // scope property produced an exception where a blank one produced a validation result + // (issue #3350). The validated property itself cannot be absent here — + // `$shouldInvokeValidation()` skips the validation in that case — so this guard only + // ever fires for scopes. Treat absent as blank, which is the branch below that turns + // an empty numeric into `IS NULL`. + local.value = StructKeyExists(this, arguments.property) ? this[arguments.property] : ""; local.part = arguments.property & "=" & variables.wheels.class.adapter.$quoteValue( - str = this[arguments.property], + str = local.value, type = validationTypeForProperty(arguments.property) ); if (Right(local.part, 3) == "=''" && ListFindNoCase("integer,float,boolean", validationTypeForProperty(arguments.property))) { diff --git a/vendor/wheels/tests/specs/model/validationsSpec.cfc b/vendor/wheels/tests/specs/model/validationsSpec.cfc index 6dfdc974d..1d9684313 100644 --- a/vendor/wheels/tests/specs/model/validationsSpec.cfc +++ b/vendor/wheels/tests/specs/model/validationsSpec.cfc @@ -1015,6 +1015,32 @@ component extends="wheels.WheelsTest" { expect(combiKey.valid()).toBeFalse() }) + // Issue #3350: a property named in `scope=` that was never assigned is ABSENT + // from the object, not present-and-empty — `$setDefaultValues()` only seeds + // properties with an explicit `property()` mapping. The scope dereference had no + // existence guard, so building the uniqueness WHERE clause threw + // "Component [X] has no accessible Member with name [Y]" out of a method whose + // entire job is to return a validation result. The validated property itself + // cannot hit this — `$shouldInvokeValidation()` skips the validation when it is + // absent — so only `scope=` was exposed. + it("validatesUniquenessOf_with_absent_scope_property", () => { + combiKey = g.model("combiKey").new(id1 = 1, id2 = 1) + StructDelete(combiKey, "id2") + + // must return a validation result rather than throwing + expect(combiKey.valid()).toBeFalse() + }) + + // An absent scope property must behave exactly like a present-but-empty one — + // the split between the two was the defect, not the value itself. + it("validatesUniquenessOf_absent_scope_matches_blank_scope", () => { + absent = g.model("combiKey").new(id1 = 1, id2 = "") + StructDelete(absent, "id2") + blank = g.model("combiKey").new(id1 = 1, id2 = "") + + expect(absent.$buildWhereClausePart("id2")).toBe(blank.$buildWhereClausePart("id2")) + }) + it("validatesUniquenessOf_with_blank_property_value", () => { user.blank = "" user.validatesUniquenessOf(properties = "blank")