Skip to content

Commit 75e184c

Browse files
bpamiriclaude
andauthored
fix(model): apply a leading ! in validation conditions to its own clause only (#3662)
$evaluateConditionString() stripped a leading `!` and inverted the result of the whole remaining string before the top-level `||`/`&&` split ran, so `!a || b` was evaluated as `!(a || b)` and `!a && b` as `!(a && b)`. CFML binds `!` tighter than `&&` and `||`. Only negate when the rest after the `!` is a single clause or a fully wrapped parenthesised group (no top-level `||`/`&&`); otherwise fall through to the compound split, where each operand recurses and negates just itself. `!(a || b)` still negates the whole group. Claude-Session: https://claude.ai/code/session_018RdXpVKos1AZ24dM4X8wL1 Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d2dfa7 commit 75e184c

3 files changed

Lines changed: 47 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Model validations: a leading `!` in a compound `condition`/`unless` expression now negates only the clause it prefixes, matching CFML precedence (`!` binds tighter than `&&`, which binds tighter than `||`). Previously `$evaluateConditionString()` stripped the `!` and inverted the whole remaining expression, so `!isNew() || this.force eq 1` was evaluated as `!(isNew() || this.force eq 1)` and the validation ran or was skipped on the wrong records. A negated parenthesised group such as `!(a || b)` still negates the whole group

‎vendor/wheels/model/validations.cfm‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -854,17 +854,28 @@
854854
public any function $evaluateConditionString(required string condition) {
855855
local.normalized = $normalizeConditionOperators(arguments.condition);
856856
857-
// A leading `!` negates the whole clause (matching CFML's reading of
858-
// `!x`). It cannot be handled further down: `$splitConditionOnOperator()`
857+
// A leading `!` negates the single clause (or parenthesised group) it
858+
// prefixes. It cannot be handled further down: `$splitConditionOnOperator()`
859859
// splits on whitespace, so a negated call with arguments would be torn
860860
// into `!StructKeyExists(this,` / `'x')`. Strip it here, evaluate the rest
861861
// as a unit, and invert the result.
862862
//
863+
// `!` binds tighter than `&&`/`||`, so when the rest still has a top-level
864+
// `||` or `&&` (`!a || b`) the `!` belongs to the first operand only: fall
865+
// through and let the compound split run first — each side recurses back
866+
// here and negates just its own clause. `!(a || b)` has no top-level
867+
// operator after the `!`, so the whole group is still negated.
868+
//
863869
// `!=` is already rewritten to ` neq ` by the normalizer above, so any
864870
// `!` reaching this point is negation.
865-
local.negate = (Left(local.normalized, 1) == "!");
866-
if (local.negate) {
867-
local.normalized = Trim(Mid(local.normalized, 2, Len(local.normalized)));
871+
if (Left(local.normalized, 1) == "!") {
872+
local.rest = Trim(Mid(local.normalized, 2, Len(local.normalized)));
873+
if (
874+
ArrayLen($splitTopLevelCondition(local.rest, "||")) == 1
875+
&& ArrayLen($splitTopLevelCondition(local.rest, "&&")) == 1
876+
) {
877+
return !$evaluateConditionString(local.rest);
878+
}
868879
}
869880
870881
// Parenthesised group, e.g. `(a || b) && c`. Unwrap only when the parens
@@ -877,14 +888,13 @@
877888
}
878889
}
879890
880-
local.result = $evaluateSingleConditionString(local.normalized);
881-
return local.negate ? !local.result : local.result;
891+
return $evaluateSingleConditionString(local.normalized);
882892
}
883893
884894
/**
885-
* Evaluates one clause — no leading negation, no surrounding group. Split out
886-
* of `$evaluateConditionString()` so the negation wrapper above can invert the
887-
* finished result instead of the first operand.
895+
* Evaluates one clause or compound expression — no negated single clause, no
896+
* surrounding group. Split out of `$evaluateConditionString()` so the negation
897+
* wrapper above can invert a finished clause instead of its first token.
888898
*/
889899
public any function $evaluateSingleConditionString(required string condition) {
890900
local.normalized = arguments.condition;

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,32 @@ component extends="wheels.WheelsTest" {
430430
expect(user.$evaluateConditionString("1 eq 0 || !ListFind('a,b,c', 'z')")).toBeTrue()
431431
})
432432

433+
// `!` binds tighter than `&&`/`||`: a leading `!` negates only the first
434+
// operand, not the whole compound. It used to invert the entire
435+
// expression, so `!a || b` was evaluated as `!(a || b)`.
436+
it("applies a leading ! only to the first operand of an || compound", () => {
437+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'username') || 1 eq 1")).toBeTrue()
438+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'nonexistent') || 1 eq 0")).toBeTrue()
439+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'username') || 1 eq 0")).toBeFalse()
440+
})
441+
442+
it("applies a leading ! only to the first operand of an && compound", () => {
443+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'nonexistent') && 1 eq 0")).toBeFalse()
444+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'username') && 1 eq 0")).toBeFalse()
445+
expect(user.$evaluateConditionString("!StructKeyExists(this, 'nonexistent') && 1 eq 1")).toBeTrue()
446+
})
447+
448+
it("negates a whole parenthesised group", () => {
449+
expect(user.$evaluateConditionString("!(StructKeyExists(this, 'username') || 1 eq 0)")).toBeFalse()
450+
expect(user.$evaluateConditionString("!(StructKeyExists(this, 'nonexistent') || 1 eq 0)")).toBeTrue()
451+
expect(user.$evaluateConditionString("!(1 eq 1) || (1 eq 1)")).toBeTrue()
452+
})
453+
454+
it("negates a mid-expression operand only", () => {
455+
expect(user.$evaluateConditionString("1 eq 1 && !StructKeyExists(this, 'username')")).toBeFalse()
456+
expect(user.$evaluateConditionString("StructKeyExists(this, 'username') && !ListFind('a,b,c', 'z')")).toBeTrue()
457+
})
458+
433459
// An argument that is not present on the instance resolves to an
434460
// empty value rather than throwing, so `||` short-circuits the way
435461
// it reads. Property absence is normal on a model instance, and the

0 commit comments

Comments
 (0)