Files
purchases-ios/Sources/RulesEngine/CustomOperators/SortByOperator.swift
T
Antonio PallaresandCursor e6a0d9531c Compare strings by UTF-16 code unit in the rules engine (#7518)
* 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>
2026-09-01 10:51:39 +02:00

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) }
}
}
}