Files
purchases-ios/Sources/RulesEngine/CustomOperators/EntriesOperators.swift
T
Antonio PallaresandCursor a9be1ba1d5 Compare object keys by UTF-16 code unit in the rules engine (#7562)
* Compare strings by UTF-16 code unit in the rules engine

A JS string is a sequence of UTF-16 code units and every string operation
works on those units. Swift's String compares canonical equivalence over
Unicode scalars, so <, <=, >, >=, ==, ===, in and rc.entries key ordering
all had cases where a predicate evaluated one way on iOS and the other in
JS and Kotlin.

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

* Keep the new fixture descriptions platform-neutral

The fixture files are byte-identical across the iOS and Android engines, so
a description that explains the contract in terms of one host language's
string comparison does not read correctly in the other repository.

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

* Name the characters under test in the UTF-16 fixture descriptions

The predicates spell these strings as \uXXXX escapes so the exact code
units are unambiguous, which leaves a reader unable to tell which
character a fixture is about. Each description now shows the glyph and
its code point, and spells out the surrogate pair where one is the
point of the case.

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

* Route rc.sortBy string keys through jsStringPrecedes

The operator open-coded the UTF-16 comparison because the shared helper
did not exist yet when it landed. It does now, and the rationale lives
in the helper's doc, so the operator no longer repeats it.

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

* Compare object keys by UTF-16 code unit

A Swift dictionary keys by canonical equivalence, so é (U+00E9) and e
followed by a combining acute (U+0301) were one key: an object holding
both spellings kept only one, and a var path spelled either way resolved
against the other. JS and Kotlin keep the two apart, so a rule reading a
non-ASCII key could answer differently per platform.

Value.object now stores an ObjectValue, whose keys compare and hash by
code unit, and the JSON parser iterates the NSDictionary rather than
casting to [String: Any], which merges the keys before the engine sees
them.

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

* Fold the ObjectValue.filter condition into a where clause

SwiftLint's for_where rule, and it reads better.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:19:16 +02:00

104 lines
4.5 KiB
Swift

//
// EntriesOperators.swift
//
// Created by Antonio Pallares.
//
import Foundation
extension RulesEngine {
/// `rc.entries` and `rc.fromEntries` — convert between keyed objects and
/// the `[key, value]` pair arrays that JSON Logic iteration operators accept.
enum EntriesOperators {
/// `{"rc.entries": value}` — convert a keyed object or array into an
/// array of two-element `[key, value]` arrays so `some`, `filter`, etc.
/// can iterate it.
///
/// **Argument form**: unary operators follow `json-logic-js` spread
/// semantics via `Operators.firstArgEvaluated` — a multi-element
/// top-level array is a multi-argument call, not a single array
/// operand. So `{"rc.entries": ["a", "b"]}` uses only `"a"`. To pass a
/// literal array operand, wrap it:
/// `{"rc.entries": [["a", "b"]]}`.
///
/// - **Object**: pairs sorted **by key, in UTF-16 code unit order**.
/// Swift dictionaries have no insertion order; this deliberately
/// diverges from JS `Object.entries` insertion order. Rules must not
/// depend on insertion order.
/// - **Array**: index/value pairs with **string** keys (`"0"`, `"1"`, …),
/// matching `Object.entries(["a","b"]) === [["0","a"],["1","b"]]`.
/// - **Anything else** (null, undefined, bool, number, string): throws
/// `EvaluationError.typeMismatch`.
///
/// Strings throw rather than yielding the character pairs JS
/// `Object.entries("ab")` produces. A string reaching here means the rule
/// points at the wrong field, and iterating its characters would let that
/// rule keep evaluating to a confident wrong answer.
static func opEntries(args: Value, vars: Scope) throws -> Value {
let input = try Operators.firstArgEvaluated(args, vars: vars)
switch input {
case .object(let map):
let sortedKeys = map.keys.sorted(by: jsStringPrecedes)
let pairs = sortedKeys.map { key in
Value.array([.string(key), map[key] ?? .null])
}
return .array(pairs)
case .array(let items):
let pairs = items.enumerated().map { index, value in
Value.array([.string(String(index)), value])
}
return .array(pairs)
default:
throw EvaluationError.typeMismatch(
message: "operator 'rc.entries' expected object or array, got \(input)"
)
}
}
/// `{"rc.fromEntries": pairs}` — inverse of `rc.entries`.
///
/// **Argument form**: same spread semantics as `rc.entries`. A literal
/// pair list must be wrapped as a single operand, e.g.
/// `{"rc.fromEntries": [[["a", 1], ["b", 2]]]}`. Extra arguments are
/// silently ignored, matching `!` and `!!`.
///
/// - **Array of two-element arrays**: builds an object. Keys are coerced
/// via `jsString` (same helper `var` uses for path segments).
/// - **Duplicate keys**: last occurrence wins, matching JS.
/// - **Empty array**: `{}`.
/// - **Non-array argument**, or any entry that is not a two-element
/// array: throws `EvaluationError.typeMismatch`.
///
/// Malformed entries throw rather than being skipped. Dropping them
/// would build a partial object that reads as a legitimate result, so a
/// rule could match on the surviving keys alone.
static func opFromEntries(args: Value, vars: Scope) throws -> Value {
let input = try Operators.firstArgEvaluated(args, vars: vars)
guard case .array(let entries) = input else {
throw EvaluationError.typeMismatch(
message: "operator 'rc.fromEntries' expected array, got \(input)"
)
}
var result = ObjectValue()
for entry in entries {
guard case .array(let pair) = entry, pair.count == 2 else {
throw EvaluationError.typeMismatch(
message: "operator 'rc.fromEntries' expected a two-element "
+ "[key, value] entry, got \(entry)"
)
}
result[jsString(pair[0])] = pair[1]
}
return .object(result)
}
}
}