Skip to content

Commit b0e2e90

Browse files
committed
fix(model): guard the scope dereference in validatesUniquenessOf
`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 ab901cf 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 <peter@alurium.com>
1 parent ab901cf commit b0e2e90

4 files changed

Lines changed: 38 additions & 78 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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="<scopeProperty>", defaultValue="")` workaround is no longer needed (#3350)

public/testbox/system/stubs/F952D54F1096E25C030C8E3149ABD8C4.cfm

Lines changed: 0 additions & 77 deletions
This file was deleted.

vendor/wheels/model/validations.cfc

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,8 +783,18 @@ component {
783783
* and converting blank numeric properties to IS NULL.
784784
*/
785785
public string function $buildWhereClausePart(required string property) {
786+
// A property named in `scope=` may be absent rather than empty: `$setDefaultValues()`
787+
// only seeds properties with an explicit `property()` mapping, so a column that has a
788+
// database-level default but no mapping never appears on a `new()`-ed object. Reading
789+
// it unguarded threw "has no accessible Member" out of a validation, so an absent
790+
// scope property produced an exception where a blank one produced a validation result
791+
// (issue #3350). The validated property itself cannot be absent here —
792+
// `$shouldInvokeValidation()` skips the validation in that case — so this guard only
793+
// ever fires for scopes. Treat absent as blank, which is the branch below that turns
794+
// an empty numeric into `IS NULL`.
795+
local.value = StructKeyExists(this, arguments.property) ? this[arguments.property] : "";
786796
local.part = arguments.property & "=" & variables.wheels.class.adapter.$quoteValue(
787-
str = this[arguments.property],
797+
str = local.value,
788798
type = validationTypeForProperty(arguments.property)
789799
);
790800
if (Right(local.part, 3) == "=''" && ListFindNoCase("integer,float,boolean", validationTypeForProperty(arguments.property))) {

vendor/wheels/tests/specs/model/validationsSpec.cfc

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,32 @@ component extends="wheels.WheelsTest" {
10151015
expect(combiKey.valid()).toBeFalse()
10161016
})
10171017

1018+
// Issue #3350: a property named in `scope=` that was never assigned is ABSENT
1019+
// from the object, not present-and-empty — `$setDefaultValues()` only seeds
1020+
// properties with an explicit `property()` mapping. The scope dereference had no
1021+
// existence guard, so building the uniqueness WHERE clause threw
1022+
// "Component [X] has no accessible Member with name [Y]" out of a method whose
1023+
// entire job is to return a validation result. The validated property itself
1024+
// cannot hit this — `$shouldInvokeValidation()` skips the validation when it is
1025+
// absent — so only `scope=` was exposed.
1026+
it("validatesUniquenessOf_with_absent_scope_property", () => {
1027+
combiKey = g.model("combiKey").new(id1 = 1, id2 = 1)
1028+
StructDelete(combiKey, "id2")
1029+
1030+
// must return a validation result rather than throwing
1031+
expect(combiKey.valid()).toBeFalse()
1032+
})
1033+
1034+
// An absent scope property must behave exactly like a present-but-empty one —
1035+
// the split between the two was the defect, not the value itself.
1036+
it("validatesUniquenessOf_absent_scope_matches_blank_scope", () => {
1037+
absent = g.model("combiKey").new(id1 = 1, id2 = "")
1038+
StructDelete(absent, "id2")
1039+
blank = g.model("combiKey").new(id1 = 1, id2 = "")
1040+
1041+
expect(absent.$buildWhereClausePart("id2")).toBe(blank.$buildWhereClausePart("id2"))
1042+
})
1043+
10181044
it("validatesUniquenessOf_with_blank_property_value", () => {
10191045
user.blank = ""
10201046
user.validatesUniquenessOf(properties = "blank")

0 commit comments

Comments
 (0)