mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
Compare object keys by UTF-16 code unit in the rules engine (#7562)
* 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>
This commit is contained in:
1 parent
812c2dd37e
commit
a9be1ba1d5
17 files changed
+175
-29
No files matched your search
+2
-2
@@ -59,8 +59,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/EmergeTools/SnapshotPreviews.git",
|
||||
"state" : {
|
||||
"revision" : "d42446f0439217941a4e3a2ca58a643c1ac328c4",
|
||||
"version" : "0.14.1"
|
||||
"revision" : "4bd6eed8a0d663a2fab9ffd3f45fd566f762c62c",
|
||||
"version" : "0.18.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
0882A0238A0B15DCAA63DCA5 /* WorkflowManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA47C656406D58B636FD82D /* WorkflowManagerTests.swift */; };
|
||||
0DCCBCD6C7CA32B59407A391 /* IterationOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB697245B7ED4A8855FF027E /* IterationOperators.swift */; };
|
||||
7A8B9C0D1E2F304152637481 /* Scope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B9C0D1E2F304152637480 /* Scope.swift */; };
|
||||
7A8B9C0D1E2F304152637491 /* ObjectValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B9C0D1E2F304152637490 /* ObjectValue.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5E /* CustomOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* CustomOperators.swift */; };
|
||||
C3D4E5F60718293A4B5C03E /* EntriesOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F60718293A4B5C03D /* EntriesOperators.swift */; };
|
||||
D4E5F60718293A4B5C03D02 /* CaseOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293A4B5C03D01 /* CaseOperators.swift */; };
|
||||
@@ -3160,6 +3161,7 @@
|
||||
AAE17E010000000000000004 /* RewardVerificationStatusResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardVerificationStatusResponseTests.swift; sourceTree = "<group>"; };
|
||||
AB697245B7ED4A8855FF027E /* IterationOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IterationOperators.swift; sourceTree = "<group>"; };
|
||||
7A8B9C0D1E2F304152637480 /* Scope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Scope.swift; sourceTree = "<group>"; };
|
||||
7A8B9C0D1E2F304152637490 /* ObjectValue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ObjectValue.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5D /* CustomOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomOperators.swift; sourceTree = "<group>"; };
|
||||
C3D4E5F60718293A4B5C03D /* EntriesOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EntriesOperators.swift; sourceTree = "<group>"; };
|
||||
D4E5F60718293A4B5C03D01 /* CaseOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CaseOperators.swift; sourceTree = "<group>"; };
|
||||
@@ -6842,6 +6844,7 @@
|
||||
E32EE3749710C439BCB9AFD6 /* RulesEngine.swift */,
|
||||
2F7428736E4FABCF7AD45798 /* RulesEngineEvaluate.swift */,
|
||||
7A8B9C0D1E2F304152637480 /* Scope.swift */,
|
||||
7A8B9C0D1E2F304152637490 /* ObjectValue.swift */,
|
||||
DF4D7487E0B0348ED8ECCBD3 /* Value+JSON.swift */,
|
||||
BDE6FC116D66BBEFEE662547 /* Value.swift */,
|
||||
);
|
||||
@@ -8323,6 +8326,7 @@
|
||||
2DF8D9550354ECBAE400C547 /* EvaluationError.swift in Sources */,
|
||||
6A24585F01C6C2310AECC0B0 /* Evaluator.swift in Sources */,
|
||||
7A8B9C0D1E2F304152637481 /* Scope.swift in Sources */,
|
||||
7A8B9C0D1E2F304152637491 /* ObjectValue.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5E /* CustomOperators.swift in Sources */,
|
||||
C3D4E5F60718293A4B5C03E /* EntriesOperators.swift in Sources */,
|
||||
D4E5F60718293A4B5C03D02 /* CaseOperators.swift in Sources */,
|
||||
|
||||
@@ -16,7 +16,7 @@ import Foundation
|
||||
|
||||
struct DimensionSnapshot: Equatable, Sendable {
|
||||
|
||||
let values: [String: RulesEngine.Value]
|
||||
let values: RulesEngine.ObjectValue
|
||||
let evaluationDate: Date
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ struct DimensionResolver: Sendable {
|
||||
) async throws -> DimensionSnapshot {
|
||||
let appUserID = self.currentAppUserIDProvider()
|
||||
let date = self.dateProvider.now()
|
||||
var values: [String: RulesEngine.Value] = [
|
||||
var values: RulesEngine.ObjectValue = [
|
||||
Self.evaluatedAtKey: .int(Int64(date.timeIntervalSince1970 * 1_000))
|
||||
]
|
||||
|
||||
@@ -107,7 +107,7 @@ struct DimensionResolver: Sendable {
|
||||
private static func addPerEvaluationValues(
|
||||
_ dimensions: [String: DimensionValue],
|
||||
root: String,
|
||||
to values: inout [String: RulesEngine.Value]
|
||||
to values: inout RulesEngine.ObjectValue
|
||||
) {
|
||||
let converted = DimensionValueConverter.convert(dimensions, parentPath: root)
|
||||
if !converted.isEmpty {
|
||||
@@ -125,8 +125,8 @@ private enum DimensionValueConverter {
|
||||
static func convert(
|
||||
_ dimensions: [String: DimensionValue],
|
||||
parentPath: String
|
||||
) -> [String: RulesEngine.Value] {
|
||||
return dimensions.reduce(into: [:]) { result, dimension in
|
||||
) -> RulesEngine.ObjectValue {
|
||||
return dimensions.reduce(into: RulesEngine.ObjectValue()) { result, dimension in
|
||||
let (name, value) = dimension
|
||||
guard Self.isValidName(name) else {
|
||||
Logger.warn(Strings.localRules.invalidDimensionName(name, parentPath: parentPath))
|
||||
|
||||
@@ -86,7 +86,7 @@ extension RulesEngine {
|
||||
)
|
||||
}
|
||||
|
||||
var result: [String: Value] = [:]
|
||||
var result = ObjectValue()
|
||||
for entry in entries {
|
||||
guard case .array(let pair) = entry, pair.count == 2 else {
|
||||
throw EvaluationError.typeMismatch(
|
||||
|
||||
@@ -48,7 +48,7 @@ extension RulesEngine {
|
||||
)
|
||||
}
|
||||
|
||||
var bindings: [String: Value] = [:]
|
||||
var bindings = ObjectValue()
|
||||
// Sorted so that a predicate with two invalid names fails the same
|
||||
// way on both engines.
|
||||
for name in declarations.keys.sorted() {
|
||||
|
||||
@@ -26,7 +26,7 @@ extension RulesEngine {
|
||||
/// etc.).
|
||||
/// - Returns: `true` when the predicate evaluates to a truthy value per
|
||||
/// JSON Logic rules.
|
||||
static func evaluate(predicate: Value, variables: [String: Value]) throws -> Bool {
|
||||
static func evaluate(predicate: Value, variables: ObjectValue) throws -> Bool {
|
||||
let scope = Scope(root: .object(variables))
|
||||
let result = try evaluateValue(predicate, vars: scope)
|
||||
return result.isTruthy
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// ObjectValue.swift
|
||||
//
|
||||
// Created by Antonio Pallares.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RulesEngine {
|
||||
|
||||
/// The storage behind `Value.object`, keyed by UTF-16 code unit.
|
||||
///
|
||||
/// A Swift `[String: Value]` keys by canonical equivalence, so `é`
|
||||
/// (U+00E9) and `e` followed by a combining acute (U+0301) are one key: an
|
||||
/// object holding both spellings silently keeps one, and a path spelled
|
||||
/// either way resolves against the other. JS and Kotlin compare keys by
|
||||
/// code unit and keep the two apart, which is what `jsStringEquals`
|
||||
/// already does everywhere else the engine compares strings.
|
||||
struct ObjectValue {
|
||||
|
||||
private var storage: [ObjectKey: Value]
|
||||
|
||||
init() {
|
||||
self.storage = [:]
|
||||
}
|
||||
|
||||
/// Widens a Swift dictionary. Keys it already merged stay merged, so
|
||||
/// callers that must keep every spelling build an `ObjectValue` from
|
||||
/// the start.
|
||||
init(_ dictionary: [String: Value]) {
|
||||
self.storage = .init(minimumCapacity: dictionary.count)
|
||||
for (key, value) in dictionary {
|
||||
self[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
subscript(key: String) -> Value? {
|
||||
get { self.storage[ObjectKey(string: key)] }
|
||||
set { self.storage[ObjectKey(string: key)] = newValue }
|
||||
}
|
||||
|
||||
var count: Int { self.storage.count }
|
||||
var isEmpty: Bool { self.storage.isEmpty }
|
||||
var keys: [String] { self.storage.keys.map(\.string) }
|
||||
|
||||
var first: (key: String, value: Value)? {
|
||||
self.storage.first.map { ($0.key.string, $0.value) }
|
||||
}
|
||||
|
||||
/// Shadows the `Sequence` overload, which would hand back an array of
|
||||
/// pairs rather than an object, the way `Dictionary.filter` does.
|
||||
func filter(
|
||||
_ isIncluded: ((key: String, value: Value)) throws -> Bool
|
||||
) rethrows -> Self {
|
||||
var result = Self()
|
||||
for element in self where try isIncluded(element) {
|
||||
result[element.key] = element.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RulesEngine.ObjectValue: Equatable, Hashable, Sendable {}
|
||||
|
||||
extension RulesEngine.ObjectValue: Sequence {
|
||||
|
||||
func makeIterator() -> AnyIterator<(key: String, value: RulesEngine.Value)> {
|
||||
var iterator = self.storage.makeIterator()
|
||||
return AnyIterator {
|
||||
iterator.next().map { ($0.key.string, $0.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RulesEngine.ObjectValue: ExpressibleByDictionaryLiteral {
|
||||
|
||||
typealias Key = String
|
||||
typealias Value = RulesEngine.Value
|
||||
|
||||
init(dictionaryLiteral elements: (String, RulesEngine.Value)...) {
|
||||
self.init()
|
||||
for (key, value) in elements {
|
||||
self[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ObjectKey: Hashable {
|
||||
|
||||
let string: String
|
||||
|
||||
static func == (lhs: ObjectKey, rhs: ObjectKey) -> Bool {
|
||||
RulesEngine.jsStringEquals(lhs.string, rhs.string)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
for unit in string.utf16 {
|
||||
hasher.combine(unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ extension RulesEngine {
|
||||
/// an `EvaluationError` when parsing or evaluation fails.
|
||||
static func evaluate(
|
||||
predicate: String,
|
||||
variables: [String: Value]
|
||||
variables: ObjectValue
|
||||
) -> Result<Bool, EvaluationError> {
|
||||
do {
|
||||
let predicateValue = try Value.fromJSONString(predicate)
|
||||
|
||||
@@ -21,12 +21,12 @@ extension RulesEngine {
|
||||
/// Names bound by enclosing `rc.let` calls. Unlike `current`, these
|
||||
/// survive iteration, which is what lets an inner predicate still see
|
||||
/// a value captured outside the loop.
|
||||
let bindings: [String: Value]
|
||||
let bindings: ObjectValue
|
||||
|
||||
init(root: Value) {
|
||||
self.root = root
|
||||
self.current = root
|
||||
self.bindings = [:]
|
||||
self.bindings = ObjectValue()
|
||||
}
|
||||
|
||||
func scoped(to current: Value) -> Scope {
|
||||
@@ -35,15 +35,15 @@ extension RulesEngine {
|
||||
|
||||
/// Adds names visible from here down. An inner `rc.let` reusing a name
|
||||
/// shadows the outer one.
|
||||
func binding(_ names: [String: Value]) -> Scope {
|
||||
Scope(
|
||||
current: current,
|
||||
root: root,
|
||||
bindings: bindings.merging(names) { _, inner in inner }
|
||||
)
|
||||
func binding(_ names: ObjectValue) -> Scope {
|
||||
var merged = bindings
|
||||
for (name, value) in names {
|
||||
merged[name] = value
|
||||
}
|
||||
return Scope(current: current, root: root, bindings: merged)
|
||||
}
|
||||
|
||||
private init(current: Value, root: Value, bindings: [String: Value]) {
|
||||
private init(current: Value, root: Value, bindings: ObjectValue) {
|
||||
self.current = current
|
||||
self.root = root
|
||||
self.bindings = bindings
|
||||
|
||||
@@ -53,10 +53,15 @@ extension RulesEngine.Value {
|
||||
if let array = object as? [Any] {
|
||||
return .array(try array.map(Self.fromJSONObject))
|
||||
}
|
||||
if let dict = object as? [String: Any] {
|
||||
var result: [String: RulesEngine.Value] = [:]
|
||||
result.reserveCapacity(dict.count)
|
||||
// Iterated as an `NSDictionary` because casting to `[String: Any]`
|
||||
// bridges into a Swift dictionary, which merges keys that differ only
|
||||
// in how the same character is spelled.
|
||||
if let dict = object as? NSDictionary {
|
||||
var result = RulesEngine.ObjectValue()
|
||||
for (key, value) in dict {
|
||||
guard let key = key as? String else {
|
||||
throw RulesEngine.EvaluationError.parse(message: "object key is not a string")
|
||||
}
|
||||
result[key] = try Self.fromJSONObject(value)
|
||||
}
|
||||
return .object(result)
|
||||
|
||||
@@ -30,7 +30,7 @@ extension RulesEngine {
|
||||
/// A JSON array.
|
||||
case array([Value])
|
||||
/// A JSON object.
|
||||
case object([String: Value])
|
||||
case object(ObjectValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ private extension DimensionScopeTests {
|
||||
|
||||
private extension DimensionScopeTests {
|
||||
|
||||
static let expectedValues: [String: RulesEngine.Value] = [
|
||||
static let expectedValues: RulesEngine.ObjectValue = [
|
||||
"evaluated_at": .int(1_700_000_000_000),
|
||||
"app_version": .string("1.2.3"),
|
||||
"platform": .string("ios"),
|
||||
|
||||
@@ -28,8 +28,8 @@ enum PredicateConformanceRunner {
|
||||
|
||||
/// Fixture-declared variables take precedence over the reserved
|
||||
/// constants on a name collision.
|
||||
private static func scope(for fixture: PredicateConformanceFixtureCase) -> [String: Value] {
|
||||
reservedConstants.merging(fixture.variables) { _, fixtureValue in fixtureValue }
|
||||
private static func scope(for fixture: PredicateConformanceFixtureCase) -> RulesEngine.ObjectValue {
|
||||
.init(reservedConstants.merging(fixture.variables) { _, fixtureValue in fixtureValue })
|
||||
}
|
||||
|
||||
static func run(_ fixture: PredicateConformanceFixtureCase) throws {
|
||||
|
||||
@@ -36,7 +36,7 @@ extension RulesEngine.Value: Decodable {
|
||||
} else if let array = try? container.decode([RulesEngine.Value].self) {
|
||||
self = .array(array)
|
||||
} else if let object = try? container.decode([String: RulesEngine.Value].self) {
|
||||
self = .object(object)
|
||||
self = .object(RulesEngine.ObjectValue(object))
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
|
||||
@@ -32,7 +32,7 @@ struct PredicateFixtureTests {
|
||||
/// fixtures are added or removed.
|
||||
@Test
|
||||
func fixtureCountMatchesExpected() {
|
||||
let expectedCount = 676
|
||||
let expectedCount = 681
|
||||
#expect(
|
||||
Self.fixtures.count == expectedCount,
|
||||
"Expected \(expectedCount) fixtures, loaded \(Self.fixtures.count)"
|
||||
|
||||
@@ -162,6 +162,27 @@
|
||||
"predicate": {"some": [[{"rc.entries": [{"var": "o"}]}], {"and": [{"===": [{"var": "0.0"}, "\ud83d\ude00"]}, {"===": [{"var": "1.0"}, "\uff21"]}]}]},
|
||||
"variables": {"o": {"\uff21": 1, "\ud83d\ude00": 2}},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"id": "from_entries_keeps_two_spellings_of_the_same_character_apart",
|
||||
"description": "One key is é (U+00E9), the other is é spelled e (U+0065) followed by a combining acute accent (U+0301). They render alike but are different UTF-16 code unit sequences, so the object has two entries",
|
||||
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 2]},
|
||||
"variables": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"id": "from_entries_pairs_each_spelling_with_its_own_value",
|
||||
"description": "The decomposed spelling é (U+0065 U+0301) keeps its own value instead of being folded into the precomposed é (U+00E9)",
|
||||
"predicate": {"some": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["e\u0301", 2]]]}}, {"and": [{"===": [{"var": "0"}, "e\u0301"]}, {"===": [{"var": "1"}, 2]}]}]},
|
||||
"variables": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"id": "from_entries_still_merges_a_repeated_identical_key",
|
||||
"description": "Two entries spelled with the same code units are one key, the last value winning, as in JS",
|
||||
"predicate": {"===": [{"reduce": [{"rc.entries": {"rc.fromEntries": [[["\u00e9", 1], ["\u00e9", 2]]]}}, {"+": [{"var": "accumulator"}, 1]}, 0]}, 1]},
|
||||
"variables": {},
|
||||
"expected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -180,6 +180,20 @@
|
||||
"predicate": { "===": [{ "var": ["missing_key", { "and": [] }] }, null] },
|
||||
"variables": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"id": "var_path_must_match_the_key_spelling",
|
||||
"description": "The key spells café with e (U+0065) followed by a combining acute accent (U+0301); the path spells the same glyph as é (U+00E9). Object keys are compared as UTF-16 code units, so the path does not resolve and the default applies",
|
||||
"predicate": { "===": [{ "var": ["caf\u00e9", "fallback"] }, "fallback"] },
|
||||
"variables": { "cafe\u0301": "sub" },
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"id": "var_resolves_when_the_path_matches_the_key_spelling",
|
||||
"description": "Same key as var_path_must_match_the_key_spelling, café spelled e (U+0065) + combining acute accent (U+0301), with a path spelled the same way",
|
||||
"predicate": { "===": [{ "var": ["cafe\u0301", "fallback"] }, "sub"] },
|
||||
"variables": { "cafe\u0301": "sub" },
|
||||
"expected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user