Files
purchases-ios/Sources/RulesEngine/CustomOperators/CaseOperators.swift
T
Antonio PallaresandCursor 66659ab8ef Raise an error for unresolved variables instead of degrading to null (#7460)
* Raise an error for unresolved variables instead of degrading to null

A `var` lookup that found nothing returned `null`, following the JSON
Logic spec. That null then coerces: `0` in a numeric comparison, `""`
in a string one. A rule that reads a dimension the SDK never supplied
therefore still produces an answer, and often the wrong one — a missing
`now` turns `{">": [expires, now]}` into `expires > 0`, which is true
for every subscription that ever existed.

An absent variable means "unknown", and unknown is not the same answer
as "no". The lookup now throws `EvaluationError.unresolvedVariable`
carrying the path, so the rule fails loudly and the SDK decides what an
unanswerable rule means. `LocalRulesEvaluator` already distinguishes an
error from a non-match: it keeps walking later rules and only throws if
none matched.

Absence a rule genuinely tolerates is opted into with the spec's own
escape hatch, `{"var": ["path", default]}`. A key that is present but
holds `null` is a known value and still resolves. `missing` and
`missing_some` exist to probe for absence and keep reporting it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop pinning the rendered path for the oversized float fixture

iOS renders `1e19` as `1e+19` and Android as `1.0E19`, so pinning the
path would keep this fixture from being shared verbatim between the two
engines. The fixture is about the formatter not trapping on a value past
Int64.max, not about how that magnitude is spelled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Apply the unresolved-variable contract to rc.lower and rc.upper

`rc.lower` arrived on main while this branch was open, carrying a
fixture that pinned a missing variable as a `typeMismatch` from the
operator. The lookup now fails first, so the error names the unresolved
path instead. Its companion pins that an explicit null still reaches
`rc.lower` and is refused there.

The operator's rationale for not coercing cited a missing `var`
resolving to null, which is no longer how that ends. It refuses
non-strings because an explicit null would lower to "null" and compare
equal to it, which is the part that still holds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the warn doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the expected-error path field for the case it belongs to

`path` sits next to `kind` and `operator` with nothing to say which
error it qualifies. The JSON key stays `path`, since there it appears
alongside `"error": "unresolvedVariable"` and reads unambiguously.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Describe the rc.rootVar fixture by what it does today

The description narrated the previous null-degrading behavior instead of
the case the fixture pins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Give the absent-attribute predicates a default

Two non-matching predicates read attribute paths that resolve to nothing,
which is now an error rather than a false-y null. The Android counterpart
already carried this change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Report an unanswerable audience as unavailable rather than no match

Two checkpoint tests pinned the null-degrading behavior: an audience
reading a dimension no provider supplies was merely false, so it read as
noMatch, and under negation it matched everyone.

The lookup throws now, which routes both through the resolver's existing
decision that an audience the SDK failed to evaluate is not the same
answer as an audience the customer falls outside of.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 12:38:49 +02:00

63 lines
2.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// CaseOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
extension RulesEngine {
/// `rc.lower` and `rc.upper` — locale-independent case conversion for
/// case-insensitive rule matching.
enum CaseOperators {
/// `{"rc.lower": value}` — lowercases a string argument.
///
/// Uses Swift's parameterless `String.lowercased()`, which applies
/// Unicode default case mapping and is **locale-independent**. Do not
/// use `Locale`-taking overloads: locale-sensitive conversion would make
/// the same rule match on one device (e.g. Turkish *I* → dotless *ı*)
/// and fail on another. Matches JS `toLowerCase()`, not
/// `toLocaleLowerCase()`.
///
/// Follows `Operators.firstArgEvaluated` spread semantics; extra
/// arguments are silently ignored.
static func opLower(args: Value, vars: Scope) throws -> Value {
let string = try stringArgument(args, vars: vars, operatorName: "rc.lower")
return .string(string.lowercased())
}
/// `{"rc.upper": value}` — same string-only contract as `rc.lower`, then
/// uppercases via locale-independent `String.uppercased()`.
static func opUpper(args: Value, vars: Scope) throws -> Value {
let string = try stringArgument(args, vars: vars, operatorName: "rc.upper")
return .string(string.uppercased())
}
/// Requires a string operand, throwing `EvaluationError.typeMismatch`
/// for anything else.
///
/// Non-strings are **not** coerced through `jsString`, even though JS
/// `String(x).toLowerCase()` would accept them. Coercion makes data of
/// the wrong shape look like real data: an explicit `null` would lower
/// to `"null"` and compare equal to that string. Same reasoning as
/// `rc.length` and `rc.entries`.
private static func stringArgument(
_ args: Value,
vars: Scope,
operatorName: String
) throws -> String {
let input = try Operators.firstArgEvaluated(args, vars: vars)
guard case .string(let string) = input else {
throw EvaluationError.typeMismatch(
message: "operator '\(operatorName)' expected string, got \(input)"
)
}
return string
}
}
}