mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-10 22:45:55 +05:45
* 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>
104 lines
4.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|