Files
purchases-ios/Sources/RulesEngine/Operators/AccessorOperators.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

269 lines
11 KiB
Swift

//
// AccessorOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
extension RulesEngine {
/// `var` and `missing` — the data-accessor operators.
enum AccessorOperators {
/// `{"var": "subscriber.last_seen_country"}` — look up a (possibly
/// nested) value by dot-path. `{"var": ["path", default]}` returns
/// `default` when the path is missing; an `undefined` default is
/// coerced to `.null`, mirroring `json-logic-js`'s
/// `not_found = (b === undefined) ? null : b`. `{"var": ""}` returns
/// the entire data scope.
///
/// A path that does not resolve and carries no default throws
/// `EvaluationError.unresolvedVariable` rather than degrading to
/// `null`. A key that *is* present but holds an explicit `null` is a
/// known value, not a missing one, and resolves normally.
///
/// Per the JSON Logic spec, the path and default arguments are
/// recursively evaluated before lookup (e.g.
/// `{"var": {"var": "active_path_key"}}` resolves `active_path_key`
/// first and uses its string value as the path).
///
/// - Parameter vars: The JSON Logic evaluation scope — `current` is
/// the data `var` reads from; path/default args evaluate against
/// `current` as well.
static func opVar(args: Value, vars: Scope) throws -> Value {
try resolveVar(args: args, target: vars.current, vars: vars, operatorName: "var")
}
/// Shared lookup for `var` and `rc.rootVar`. Path and default args
/// evaluate against `vars.current`; the final lookup walks `target`.
static func resolveVar(
args: Value,
target: Value,
vars: Scope,
operatorName: String
) throws -> Value {
let (path, defaultValue) = try resolveVarArgs(args, vars: vars, operatorName: operatorName)
if let found = lookupVar(in: target, path: path) {
return found
}
if let defaultValue = defaultValue {
// json-logic-js coerces an `undefined` default to `null`.
if case .undefined = defaultValue { return .null }
return defaultValue
}
throw RulesEngine.EvaluationError.unresolvedVariable(path: path)
}
/// `{"missing": ["a", "b.c"]}` returns the array of keys whose `var`
/// lookup resolves to `null` (absent or `null` leaf) or to the empty
/// string. Falsy non-empty values like `0`, `false`, or `[]` are NOT
/// reported as missing. Returns `[]` when nothing is missing.
///
/// Each key argument is recursively evaluated before lookup. If the
/// first evaluated argument is itself an array, its elements are
/// unpacked as the key list (e.g. `{"missing": {"merge": [...]}}`).
static func opMissing(args: Value, vars: Scope) throws -> Value {
let evaluatedArgs: [Value]
if case .array(let items) = args {
evaluatedArgs = try items.map { try Evaluator.evaluateValue($0, vars: vars) }
} else {
// Singleton shorthand: `{"missing": "a"}` ≡ `{"missing": ["a"]}`.
evaluatedArgs = [try Evaluator.evaluateValue(args, vars: vars)]
}
// Per JSON Logic spec: if the first arg resolves to an array,
// treat its elements as the full key list.
let keys: [Value]
if let first = evaluatedArgs.first, case .array(let innerKeys) = first {
keys = innerKeys
} else {
keys = evaluatedArgs
}
var missing: [Value] = []
for key in keys {
guard let path = keyAsPath(key) else { continue }
if isMissingValue(varLookup(in: vars.current, path: path)) {
missing.append(.string(path))
}
}
return .array(missing)
}
/// `{"missing_some": [min_required, [path, ...]]}` returns the
/// missing-keys array (same shape as `missing`) IF fewer than
/// `min_required` of the requested paths are present. Otherwise
/// returns `[]`.
static func opMissingSome(args: Value, vars: Scope) throws -> Value {
let evaluated = try Operators.evalArgs(args, vars: vars)
guard evaluated.count == 2 else {
throw RulesEngine.EvaluationError.typeMismatch(
message: "operator 'missing_some' expects 2 arguments, got \(evaluated.count)"
)
}
let needCountValue = evaluated[0]
let options = evaluated[1]
// json-logic-js computes `missing.apply(this, [options])` for the
// keys, then reads `options.length` for the threshold. So the key
// set and the threshold count come from *different* views of
// `options`:
// - array → its elements are the keys; length = element count
// - string → the *whole string* is a single key; length = its
// UTF-16 code-unit count (matching JS `String.length`),
// so a long string can satisfy a larger threshold while
// only ever contributing one key
// - null → no keys; `length` is `undefined`, which makes the
// threshold comparison `NaN >= need` (always false), so
// the missing list is returned unconditionally
// - other → `Function.prototype.apply` throws a `TypeError`
let keys: [Value]
let total: Int?
switch options {
case .array(let items):
keys = items
total = items.count
case .string(let string):
keys = [.string(string)]
total = string.utf16.count
case .null, .undefined:
keys = []
total = nil
default:
throw RulesEngine.EvaluationError.typeMismatch(
message: "operator 'missing_some': second argument must be array-like, "
+ "got \(options)"
)
}
// Threshold uses JS `ToNumber` + `>=`. `NaN` and unparseable
// strings never satisfy; `+Infinity` never satisfies for finite
// present counts; `-Infinity` always satisfies.
let need = jsToNumber(needCountValue)
let missing = try opMissing(args: .array(keys), vars: vars)
let missingCount: Int
if case .array(let entries) = missing {
missingCount = entries.count
} else {
missingCount = 0
}
if let total, Double(total - missingCount) >= need {
return .array([])
}
return missing
}
// MARK: - Helpers
/// Recursively evaluate `var`'s arg(s) per the JSON Logic spec, then
/// normalize the result into a `(path, default)` tuple.
private static func resolveVarArgs(
_ args: Value,
vars: Scope,
operatorName: String
) throws -> (String, Value?) {
if case .array(let items) = args {
var evaluated: [Value] = []
evaluated.reserveCapacity(items.count)
for item in items {
evaluated.append(try Evaluator.evaluateValue(item, vars: vars))
}
return parseVarArrayArgs(evaluated, operatorName: operatorName)
}
let evaluated = try Evaluator.evaluateValue(args, vars: vars)
return (pathSegment(from: evaluated), nil)
}
private static func parseVarArrayArgs(_ items: [Value], operatorName: String) -> (String, Value?) {
let path = pathSegment(from: items.first)
let defaultValue: Value? = items.count >= 2 ? items[1] : nil
if items.count > 2 {
RulesEngine.logger.warn(
"\(operatorName): ignoring \(items.count - 2) extra arg(s); expected [path] or [path, default]"
)
}
return (path, defaultValue)
}
/// Coerce the evaluated path argument to a string per
/// `json-logic-js`'s `String(a).split(".")`. `nil`, `.null`,
/// `.undefined`, and `""` are treated as the empty path, which signals
/// the caller to return the entire data scope — matching json-logic-js's
/// `typeof a === "undefined" || a === "" || a === null` guard.
private static func pathSegment(from value: Value?) -> String {
switch value {
case .none, .some(.null), .some(.undefined):
return ""
case .some(let other):
return jsString(other)
}
}
/// `missing` routes each key through `var`, where `.null`/`.undefined`
/// resolve to the full scope (so they are never "missing"). Returning
/// `nil` here skips them, matching json-logic-js.
private static func keyAsPath(_ value: Value) -> String? {
switch value {
case .null, .undefined:
return nil
default:
return jsString(value)
}
}
/// Resolve `path` the way `var` does. Empty path returns the entire
/// data scope; a resolving path returns its value (including explicit
/// `.null`); a non-resolving path returns `nil`.
private static func lookupVar(in vars: Value, path: String) -> Value? {
if path.isEmpty {
return vars
}
return lookupPath(in: vars, path: path)
}
/// Like `lookupVar`, but maps a non-resolving path to `.null`
/// instead of `nil` — the shape `missing` needs.
private static func varLookup(in vars: Value, path: String) -> Value {
lookupVar(in: vars, path: path) ?? .null
}
/// `missing` reports a key when its `var` lookup resolves to `null`
/// or to the empty string. Falsy non-empty values (`0`, `false`,
/// `[]`) are NOT missing.
private static func isMissingValue(_ value: Value) -> Bool {
switch value {
case .null:
return true
case .string(let stringValue):
return stringValue.isEmpty
default:
return false
}
}
/// Walk `vars` following `path` (dot-separated). Numeric segments index
/// into arrays; string segments key into objects. Returns `nil` if any
/// segment can't resolve.
private static func lookupPath(in vars: Value, path: String) -> Value? {
var current = vars
for segment in path.split(separator: ".", omittingEmptySubsequences: false) {
switch current {
case .object(let map):
guard let next = map[String(segment)] else { return nil }
current = next
case .array(let items):
guard let idx = Int(segment), idx >= 0, idx < items.count else { return nil }
current = items[idx]
default:
return nil
}
}
return current
}
}
}