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> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
113 lines
4.3 KiB
Swift
113 lines
4.3 KiB
Swift
//
|
|
// SortByOperator.swift
|
|
//
|
|
// Created by Antonio Pallares.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Name used in this operator's error messages.
|
|
private let operatorName = "rc.sortBy"
|
|
|
|
extension RulesEngine {
|
|
|
|
/// `rc.sortBy` — orders an array in ascending order by a key computed per item.
|
|
enum SortByOperator {
|
|
|
|
/// `{"rc.sortBy": [array, keyExpression]}` — the original items in
|
|
/// ascending key order, ties keeping their input order.
|
|
///
|
|
/// `keyExpression` is evaluated once per item with the scope rebound to
|
|
/// that item, the same way `map` evaluates its template.
|
|
///
|
|
/// Keys must be all strings or all finite numbers; anything else,
|
|
/// including a mix, throws `EvaluationError.typeMismatch`. String keys
|
|
/// order by UTF-16 code unit, via `jsStringPrecedes`. There is no
|
|
/// direction argument.
|
|
static func opSortBy(args: Value, vars: Scope) throws -> Value {
|
|
let raw = Operators.argsAsList(args)
|
|
|
|
guard raw.count == 2 else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expects 2 arguments, got \(raw.count)"
|
|
)
|
|
}
|
|
|
|
let source = try Evaluator.evaluateValue(raw[0], vars: vars)
|
|
guard case .array(let items) = source else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected an array to sort, got \(source)"
|
|
)
|
|
}
|
|
|
|
let keys = try items.map { item in
|
|
try Evaluator.evaluateValue(raw[1], vars: vars.scoped(to: item))
|
|
}
|
|
|
|
return .array(try Self.sorted(items, by: keys))
|
|
}
|
|
|
|
/// Sorts by `keys`, falling back to the input position so equal keys
|
|
/// keep their order. Swift does not guarantee `sorted(by:)` is stable.
|
|
private static func sorted(_ items: [Value], by keys: [Value]) throws -> [Value] {
|
|
let order = try Self.comparator(for: keys)
|
|
|
|
return zip(keys.indices, zip(keys, items))
|
|
.sorted { left, right in
|
|
let (leftIndex, (leftKey, _)) = left
|
|
let (rightIndex, (rightKey, _)) = right
|
|
if order(leftKey, rightKey) { return true }
|
|
if order(rightKey, leftKey) { return false }
|
|
return leftIndex < rightIndex
|
|
}
|
|
.map { _, pair in pair.1 }
|
|
}
|
|
|
|
/// Picks the ordering the whole key set supports, or throws.
|
|
private static func comparator(for keys: [Value]) throws -> (Value, Value) -> Bool {
|
|
var sawString = false
|
|
var sawNumber = false
|
|
|
|
for key in keys {
|
|
switch key {
|
|
case .string:
|
|
sawString = true
|
|
case .int:
|
|
sawNumber = true
|
|
case .float(let number):
|
|
// A NaN key compares false against everything, so every
|
|
// pair would tie and the input would come back unsorted.
|
|
guard number.isFinite else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected a finite number key, "
|
|
+ "got \(key)"
|
|
)
|
|
}
|
|
sawNumber = true
|
|
default:
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected string or number keys, got \(key)"
|
|
)
|
|
}
|
|
}
|
|
|
|
guard !(sawString && sawNumber) else {
|
|
throw EvaluationError.typeMismatch(
|
|
message: "operator '\(operatorName)' expected keys of a single type, "
|
|
+ "got both strings and numbers"
|
|
)
|
|
}
|
|
|
|
if sawString {
|
|
return { left, right in
|
|
guard case .string(let left) = left, case .string(let right) = right else {
|
|
return false
|
|
}
|
|
return jsStringPrecedes(left, right)
|
|
}
|
|
}
|
|
return { left, right in (left.asNumber ?? 0) < (right.asNumber ?? 0) }
|
|
}
|
|
}
|
|
}
|