Validation conditions now fail closed: migrating your 2.x condition strings
CFWheels 2.x handed every condition= and unless= string to CFML’s Evaluate(). Any expression the engine could run was accepted. Wheels 4.1 and later don’t do that. It parses a small, fixed grammar, and since 4.1.0 a string it can’t parse throws Wheels.InvalidValidationCondition instead of skipping the rule. This post covers what 4.1.2 accepts, what it does with the rest, and how we rewrote real 2.x condition strings and tested them on the example app.
What changed, release by release
- 2.5.1 ran
Evaluate(arguments[local.item])on each string. If that failed, it swapped==,!=,<and the rest foreq,neq,ltand so on, and tried again. - 3.0 replaced
Evaluate()with a parser. When parsing failed, it returnedfalse, which quietly skipped the validation. - 4.0.6 threw
Wheels.InvalidValidationConditionwhenshowErrorInformationwas on, as it is in development. In production it logged the error and still skipped the rule. - 4.1.0 made that path fail closed: an unparseable condition throws even when
showErrorInformationis off (#3398). - 4.1.1 widened the grammar to cover what 4.1.0 had started rejecting:
&&/||, parentheses, and a whitelist of read-only functions (#3634, fixed in #3636).
The CHANGELOG’s upgrade note puts it plainly: treat the 4.1 upgrade as “the moment previously-inert validations start being enforced”. A condition that throws is an HTTP 500 on the save that triggers it.
The grammar 4.1.2 accepts
this.propertyandthis.method(), with named (key='val') or positional ('val') arguments.- A bare zero-argument call on the model:
isNew(),!isNew(). - Comparisons with
eq neq lt lte gt gteor== != < <= > >=. The right-hand side is a literal: a number, or a string with or without quotes. &&and||(&&binds tighter),!, and parentheses.- These functions only:
StructKeyExists,IsNull,IsNumeric,IsSimpleValue,IsStruct,IsArray,IsBoolean,IsDate,Len,ListFind,ListFindNoCase,ListContains,ListContainsNoCase. Their arguments can bethis,this.x, quoted strings, numbers or booleans.
Anything else is either rejected or, in a few shapes, read the wrong way. That second group is the one to worry about.
What 4.1.2 does with 2.x strings
We took ten 2.x-style strings and ran each one on 4.1.2 under Lucee 7. Three come from the 2.5.1 docs: the guide’s condition="not isLoggedIn()" and unless="this.name is 'Ben Forta'", and the API reference’s unless="DayOfWeek() eq 1". The other seven are representative shapes we wrote, not taken from a real app. Each spec calls $evaluateCondition(), the check valid() runs before each rule:
| 2.x string | Record | On 4.1.2 |
|---|---|---|
condition="not isLoggedIn()" |
logged out | throws |
condition="Len(password)" |
any | throws |
condition="IsDefined('this.discountCode')" |
any | throws |
unless="this.name is 'Ben Forta'" |
name is Ben Forta | rule runs anyway |
unless="DayOfWeek() eq 1" |
any day | rule runs every day |
condition="status != 'draft'" |
a draft | rule runs anyway |
condition="isActive" |
active | rule skipped |
condition="isActive eq" |
active | rule skipped |
condition="this.kind eq 'b2b' and this.country eq 'SE'" |
Swedish B2B | rule skipped |
condition="this.total gt this.limit" |
total 50, limit 10 | rule skipped |
The three that throw are the easy ones, because the error names the problem:
The `condition` expression `Len(password)` could not be evaluated: Unsupported argument `password` in a condition function call. Supported arguments are `this`, `this.property`, `this.method()`, quoted strings, booleans and numbers — a bare variable name is not resolved (use `this.password`).
The other seven raise no error. They give the wrong answer:
- A bare name on the left is compared as text.
status != 'draft'compares the wordstatuswithdraft, so the rule always runs.DayOfWeek()is compared as the textDayOfWeek(). - Fewer than three tokens evaluates to
false.isActiveandisActive eqskip the rule whatever the record holds. - After
this., unknown words become part of the operand.isisn’t an operator, sothis.name is 'Ben Forta'looks up a property literally namedname is 'Ben Forta', finds nothing and returnsfalse. Withand, everything after the firsteqbecomes one string on the right-hand side, sothis.kindis compared withb2b and this.country eq SE. Athis.reference on the right is compared as the textthis.limit.
Rewrites
Each rewrite below is a rule in app/models/ConditionDemo.cfc, and a spec runs it through valid() both ways.
// 2.x: condition="not isLoggedIn()", unless="this.name is 'Ben Forta'"
validatesPresenceOf(property="captcha", condition="!isLoggedIn()", unless="this.name eq 'Ben Forta'");
// 2.x: condition="status != 'draft'"
validatesPresenceOf(property="publishedAt", condition="this.status != 'draft'");
// 2.x: condition="isActive"
validatesPresenceOf(property="activatedBy", condition="this.isActive");
// 2.x: condition="Len(password)"
validatesConfirmationOf(property="password", condition="Len(this.password)");
// 2.x: condition="this.kind eq 'b2b' and this.country eq 'SE'"
validatesPresenceOf(property="vatNumber", condition="this.kind eq 'b2b' && this.country eq 'SE'");
// 2.x: condition="IsDefined('this.discountCode')"
validatesFormatOf(property="discountCode", regEx="^[A-Z0-9]{6}$", condition="StructKeyExists(this, 'discountCode')");
The rule of thumb: put this. on every property, use &&, || and ! rather than and, or and not, use eq rather than is, and keep this. on the left of a comparison.
The escape hatch
When the grammar can’t express a rule, such as arithmetic, a function outside the whitelist, or comparing two properties, move it into a model method and call that:
// 2.x: unless="DayOfWeek() eq 1"
validatesFormatOf(property="email", regEx="^.*@.*\.se$", condition="ipCheck()", unless="this.isSunday()");
validatesPresenceOf(property="reviewNote", condition="this.needsCheck()");
public boolean function needsCheck() {
return StructKeyExists(this, "total") && IsNumeric(this.total) && this.total > 1000 && !isSunday();
}
The method is plain CFML, so this.total gt this.limit becomes this.total > this.limit inside it. The 2.x DayOfWeek() example needed rewriting anyway: on Lucee 7, Evaluate("DayOfWeek()") fails with “To few Attributes in function [DAYOFWEEK]”, because the function needs a date.
The two production cases in the CHANGELOG
The 4.1.1 upgrade note quotes two conditions from one production app:
condition="StructKeyExists(this, 'requestFor') && this.requestFor == 'Engine Part'"
condition="ListFind('24,32,63,67,117,167,191', this.countryId)"
On 4.1.0 the first threw on every save and the second was silently inert. Both run unchanged from 4.1.1, and our spec checks each one both ways on 4.1.2. If you’re still on 4.1.0, these two shapes are the reason to move to 4.1.1 or later.
What Wheels 4.2 changes
#3929, merged to develop for Wheels 4.2 and not in 4.1.2, changes how a string that isn’t a this. reference, a call or a whitelisted function is read. In 4.2:
- A bare name, alone or on the left of a comparison, resolves as
this.<name>when the record has that property or method, and throws when it doesn’t.condition="isActive"andcondition="status != 'draft'"will work as written. - A bare word on the right stays a string literal, numbers and
true/falseare typed, and a lonetrueorfalseis its own value. - A
this.reference on the right of such a comparison, or the wrong number of tokens, throws.
We ran our spec against that version of validations.cfm: isActive eq and DayOfWeek() eq 1 throw, and isActive and status != 'draft' give the intended answer. #3929 leaves the this. path unchanged, though. this.name is 'Ben Forta', the and form and this.total gt this.limit still give the same wrong answers, so rewrite those whatever version you’re on. Wheels 4.2 throws on them too (#3970, for #3964): a right-hand this., word-form and/or, and operators such as is. A condition you leave unchanged will then fail loudly instead of quietly.
The 4.1 validation guide uses this.status != 'draft' and lists the grammar 4.1.2 accepts; the 4.2 guide describes the bare-name change.
The example app
The example app has two conditions, unless="isAuthenticated()" and condition="StructKeyExists(this, 'updatePassword')". Both parse on 4.1.2 unchanged, as Pass 2 showed, so 1661b3c adds the demo model and tests/specs/models/ConditionMigrationSpec.cfc: 24 specs, all passing on Lucee 7. We didn’t run that spec on Adobe ColdFusion. The framework’s own 56 conditional-validation specs, which cover the same grammar, passed on Adobe ColdFusion 2023 and 2025 in the compatibility matrix run on the 4.1.2 release pull request.
If you’re upgrading
-
List your conditions:
git grep -nE '(condition|unless)[[:space:]]*=' -- app config. -
Find the ones that won’t throw but are read wrong:
git grep -nE '(condition|unless)[[:space:]]*=[[:space:]]*"([^"]*[[:space:]])?(is|and|or|not)[[:space:]]' -- app git grep -nE '(condition|unless)[[:space:]]*=[[:space:]]*"[^"]*(eq|neq|lt|lte|gt|gte|==|!=|<|>)[[:space:]]*this\.' -- app -
Add
this.to every bare property name. -
Move anything else into a model method and call it with
this.method(). -
Test each condition both ways with
valid(). A grep can’t tell you whether a rule runs.
Previous: the public/Application.cfc edits between 4.0 and 4.1. Next in the series: replacing your 2.x plugins.
Comments