mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +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> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
5.7 KiB
Swift
141 lines
5.7 KiB
Swift
//
|
|
// ComparisonOperators.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension RulesEngine {
|
|
|
|
/// Comparison operators: `<`, `<=`, `>`, `>=`.
|
|
///
|
|
/// Per JSON Logic:
|
|
///
|
|
/// - After `ToPrimitive` (number hint), **both operands are strings**
|
|
/// → lexicographic comparison (`"10" < "9"` is `true`). Arrays and
|
|
/// objects stringify first (`[] < "a"` → `"" < "a"` → `true`).
|
|
/// - **Otherwise** → coerce both operands to `Double` via
|
|
/// `Value.asNumber` and compare numerically (`"10" < 9` → `false`).
|
|
/// Unparseable strings and objects compared numerically become
|
|
/// `Double.nan`; per IEEE 754 any comparison against `nan` returns
|
|
/// `false`.
|
|
///
|
|
/// `<` and `<=` accept a 3-arg "between" form per the JSON Logic spec:
|
|
/// `{"<": [a, b, c]}` reads as `a < b AND b < c`. `>` and `>=` are
|
|
/// binary only.
|
|
enum ComparisonOperators {
|
|
|
|
/// `{"<": [a, b]}` — `a < b`. `{"<": [a, b, c]}` — `a < b AND b < c`.
|
|
static func opLt(args: Value, vars: Scope) throws -> Value {
|
|
try evalChain(args, vars: vars, using: .less)
|
|
}
|
|
|
|
/// `{"<=": [a, b]}` — `a <= b`. `{"<=": [a, b, c]}` — `a <= b AND b <= c`.
|
|
static func opLe(args: Value, vars: Scope) throws -> Value {
|
|
try evalChain(args, vars: vars, using: .lessOrEqual)
|
|
}
|
|
|
|
/// `{">": [a, b]}` — `a > b`. Strictly binary.
|
|
static func opGt(args: Value, vars: Scope) throws -> Value {
|
|
try evalBinary(args, vars: vars, using: .greater)
|
|
}
|
|
|
|
/// `{">=": [a, b]}` — `a >= b`. Strictly binary.
|
|
static func opGe(args: Value, vars: Scope) throws -> Value {
|
|
try evalBinary(args, vars: vars, using: .greaterOrEqual)
|
|
}
|
|
|
|
// swiftlint:disable:next nesting
|
|
private enum Comparator {
|
|
case less, lessOrEqual, greater, greaterOrEqual
|
|
|
|
func apply<T: Comparable>(_ lhs: T, _ rhs: T) -> Bool {
|
|
switch self {
|
|
case .less: return lhs < rhs
|
|
case .lessOrEqual: return lhs <= rhs
|
|
case .greater: return lhs > rhs
|
|
case .greaterOrEqual: return lhs >= rhs
|
|
}
|
|
}
|
|
|
|
/// Strings order by UTF-16 code unit rather than through Swift's
|
|
/// `Comparable`; see `jsStringEquals`.
|
|
func apply(strings lhs: String, _ rhs: String) -> Bool {
|
|
switch self {
|
|
case .less: return jsStringPrecedes(lhs, rhs)
|
|
case .lessOrEqual: return !jsStringPrecedes(rhs, lhs)
|
|
case .greater: return jsStringPrecedes(rhs, lhs)
|
|
case .greaterOrEqual: return !jsStringPrecedes(lhs, rhs)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mirrors JS `<`: `ToPrimitive` (number hint), lex when both are
|
|
/// strings, else numeric. `nil` lhs/rhs is an omitted argument
|
|
/// (`undefined` → `NaN` → `false`).
|
|
private static func compare(_ lhs: Value?, _ rhs: Value?, using cmp: Comparator) -> Bool {
|
|
guard let lhs, let rhs else {
|
|
return cmp.apply(asDouble(lhs), asDouble(rhs))
|
|
}
|
|
let left = toPrimitiveForComparison(lhs)
|
|
let right = toPrimitiveForComparison(rhs)
|
|
if case .string(let leftString) = left, case .string(let rightString) = right {
|
|
return cmp.apply(strings: leftString, rightString)
|
|
}
|
|
return cmp.apply(asDouble(left), asDouble(right))
|
|
}
|
|
|
|
/// `ToPrimitive` with number hint: arrays/objects stringify; other
|
|
/// primitives pass through unchanged.
|
|
private static func toPrimitiveForComparison(_ value: Value) -> Value {
|
|
switch value {
|
|
case .string, .null, .undefined, .bool, .int, .float:
|
|
return value
|
|
case .array, .object:
|
|
return .string(jsString(value))
|
|
}
|
|
}
|
|
|
|
/// Shared 2-or-3 arg "chain" evaluator used by `<` and `<=`.
|
|
/// `json-logic-js` declares the operator as `function(a, b, c)`:
|
|
/// missing operands resolve to `undefined` (NaN comparisons are
|
|
/// always `false`); the 3-arg form is the between-form
|
|
/// (`a < b AND b < c`); arguments past the third are dropped.
|
|
private static func evalChain(
|
|
_ args: Value,
|
|
vars: Scope,
|
|
using cmp: Comparator
|
|
) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
let lhs = evaluated.first
|
|
let mid = evaluated.indices.contains(1) ? evaluated[1] : nil
|
|
if evaluated.count >= 3 {
|
|
let rhs = evaluated[2]
|
|
return .bool(compare(lhs, mid, using: cmp) && compare(mid, rhs, using: cmp))
|
|
}
|
|
return .bool(compare(lhs, mid, using: cmp))
|
|
}
|
|
|
|
/// Shared 2-arg evaluator used by `>` and `>=`. `json-logic-js`
|
|
/// declares them as `function(a, b)`: extras are silently dropped
|
|
/// and a missing operand becomes NaN (which makes any comparison
|
|
/// `false`).
|
|
private static func evalBinary(
|
|
_ args: Value,
|
|
vars: Scope,
|
|
using cmp: Comparator
|
|
) throws -> Value {
|
|
let evaluated = try Operators.evalArgs(args, vars: vars)
|
|
let lhs = evaluated.first
|
|
let rhs = evaluated.indices.contains(1) ? evaluated[1] : nil
|
|
return .bool(compare(lhs, rhs, using: cmp))
|
|
}
|
|
|
|
/// Omitted arg or failed coercion → `nan`; `.null` → 0.
|
|
private static func asDouble(_ value: Value?) -> Double {
|
|
value?.asNumber ?? .nan
|
|
}
|
|
}
|
|
}
|