mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Add rc.sortBy custom operator Orders an array by a key computed per item, ascending, with ties keeping their input order. Keys must be all strings or all numbers so the two engines never have to invent a cross-type ordering. Co-authored-by: Cursor <cursoragent@cursor.com> * Reject non-finite rc.sortBy keys A NaN key compares false in both directions, so every pair tied and the input came back unsorted with no error. Negating a string key to attempt a descending sort produced exactly that. Co-authored-by: Cursor <cursoragent@cursor.com> * Note ascending order in the rc.sortBy summary Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the rc.sortBy doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the rc.sortBy stability comment Co-authored-by: Cursor <cursoragent@cursor.com> * Order rc.sortBy string keys by UTF-16 code unit Swift's < orders by Unicode scalar and treats canonically equivalent strings as equal, while JS and Kotlin order by UTF-16 code unit. The two disagree once a scalar above the surrogate range meets an astral one, so the same rule would sort differently on iOS and Android. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the rc.sortBy ordering fixtures 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> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
117 lines
4.6 KiB
Swift
117 lines
4.6 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`. 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
|
|
}
|
|
// Swift orders by Unicode scalar and treats canonically
|
|
// equivalent strings as equal; JS and Kotlin both order by
|
|
// UTF-16 code unit. The two disagree once a scalar above
|
|
// the surrogate range meets an astral one, so follow the
|
|
// engines the rules are written against.
|
|
return left.utf16.lexicographicallyPrecedes(right.utf16)
|
|
}
|
|
}
|
|
return { left, right in (left.asNumber ?? 0) < (right.asNumber ?? 0) }
|
|
}
|
|
}
|
|
}
|