From 6952e7b9bb43edfc61aff9fd9b23bd6fd9077b6d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 3 Aug 2026 22:06:15 -0700 Subject: [PATCH] fix(model): guard the scope dereference in validatesUniquenessOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validatesUniquenessOf(property="x", scope="y")` threw Component [MyModel] has no accessible Member with name [y] whenever `y` had never been assigned — out of a method whose entire job is to return a validation result. A caller that correctly wraps `save()` in an `if` still got an exception, and it surfaced as a 500. `$buildWhereClausePart()` read `this[arguments.property]` with no existence guard. That is easy to hit because absent and empty are different states here: `$setDefaultValues()` only seeds properties that have an explicit `property()` mapping, so a column with a database-level default but no mapping is missing from a `new()`-ed object rather than present-and-blank. The `scope=` in the model source looks fine; nothing in it hints that the property will be absent at validation time. Only scopes were exposed. The validated property itself cannot reach this code absent — `$shouldInvokeValidation()` returns false when it is missing from the object — which is why the split went unnoticed: "unset" and "set to empty" produced an exception and a validation result respectively, for the same logical state. An absent scope property is now treated as blank, so both states take the identical path, including the existing conversion of an empty numeric scope to `IS NULL`. The guard mirrors `validationTypeForProperty()` and `key()` in the same layer, which already read optional properties this way. Red-first: 945 pass / 0 fail / 2 errors, both errors being the reported message verbatim against the existing `CombiKey` fixture (`validatesUniquenessOf(property="id1", scope="id2")`). Verification, lucee7 + sqlite, full core suite: develop ab901cff7 4732 pass / 0 fail / 0 error this branch 4734 pass / 0 fail / 0 error Exactly +2, the new specs. Closes #3350 Signed-off-by: Peter Amiri --- .../3350-uniqueness-scope-guard.fixed.md | 1 + vendor/wheels/model/validations.cfc | 12 ++++++++- .../tests/specs/model/validationsSpec.cfc | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 changelog.d/3350-uniqueness-scope-guard.fixed.md 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 0000000000..a6e6cb3800 --- /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 cfd0ff5df3..b1dffdeba7 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 6dfdc974d7..1d96843131 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")