Found building a production app on Wheels 4.0.5 / Lucee 7.
Symptom
validatesUniquenessOf(property="x", scope="y") throws
Component [MyModel] has no accessible Member with name [y]
instead of returning a normal validation failure, whenever y was never assigned on the object.
Cause
vendor/wheels/model/validations.cfc, $buildWhereClausePart() reads this[arguments.property] directly, with no StructKeyExists guard, for every property named in scope=.
This interacts with a second behaviour to make it easy to hit: $setDefaultValues() only seeds properties that have an explicit property() mapping. So a column that exists in the database and has a DB-level default — but no property(name=..., defaultValue=...) call in config() — is simply absent from a new()-ed object rather than present-and-empty. Validating uniqueness then throws rather than validating.
Reproduction
// table: memberships (user_id, tenant_id, ...)
component extends="Model" {
function config() {
validatesUniquenessOf(property="user_id", scope="tenant_id");
}
}
var m = model("Membership").new();
m.user_id = someId; // tenant_id deliberately not set
m.valid(); // throws instead of returning false
Why it matters
The failure mode is a raw runtime error surfacing as a 500, in code whose entire purpose is to return a validation result. A caller that correctly wraps save() in an if still gets an exception. And because the trigger is an absent property rather than an empty one, it is invisible in the model's source — the scope= looks fine.
Suggested fix
Guard the dereference, treating an absent scope property as an empty value (which is what a present-but-unset property already does):
local.scopeValue = StructKeyExists(this, arguments.property) ? this[arguments.property] : "";
That makes the two cases behave identically, which is almost certainly the intent — the current split means "unset" and "set to empty" produce a validation result and an exception respectively.
If throwing is deliberate, the message should name scope= as the cause; as written it reads like an unrelated model-definition error.
Workaround
Declare property(name="<scopeProperty>", defaultValue="") for every property named in a scope=.
Found building a production app on Wheels 4.0.5 / Lucee 7.
Symptom
validatesUniquenessOf(property="x", scope="y")throwsinstead of returning a normal validation failure, whenever
ywas never assigned on the object.Cause
vendor/wheels/model/validations.cfc,$buildWhereClausePart()readsthis[arguments.property]directly, with noStructKeyExistsguard, for every property named inscope=.This interacts with a second behaviour to make it easy to hit:
$setDefaultValues()only seeds properties that have an explicitproperty()mapping. So a column that exists in the database and has a DB-level default — but noproperty(name=..., defaultValue=...)call inconfig()— is simply absent from anew()-ed object rather than present-and-empty. Validating uniqueness then throws rather than validating.Reproduction
// table: memberships (user_id, tenant_id, ...) component extends="Model" { function config() { validatesUniquenessOf(property="user_id", scope="tenant_id"); } }var m = model("Membership").new(); m.user_id = someId; // tenant_id deliberately not set m.valid(); // throws instead of returning falseWhy it matters
The failure mode is a raw runtime error surfacing as a 500, in code whose entire purpose is to return a validation result. A caller that correctly wraps
save()in anifstill gets an exception. And because the trigger is an absent property rather than an empty one, it is invisible in the model's source — thescope=looks fine.Suggested fix
Guard the dereference, treating an absent scope property as an empty value (which is what a present-but-unset property already does):
That makes the two cases behave identically, which is almost certainly the intent — the current split means "unset" and "set to empty" produce a validation result and an exception respectively.
If throwing is deliberate, the message should name
scope=as the cause; as written it reads like an unrelated model-definition error.Workaround
Declare
property(name="<scopeProperty>", defaultValue="")for every property named in ascope=.