Skip to content

validatesUniquenessOf(scope=...) dereferences scope properties unguarded — throws 'has no accessible Member' instead of validating #3350

Description

@bpamiri

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=.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions