diff --git a/Package.resolved b/Package.resolved index 728a97e3a4..90c74895c7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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" } }, { diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 42c1077cfb..bca0017092 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -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 = ""; }; AB697245B7ED4A8855FF027E /* IterationOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IterationOperators.swift; sourceTree = ""; }; 7A8B9C0D1E2F304152637480 /* Scope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Scope.swift; sourceTree = ""; }; + 7A8B9C0D1E2F304152637490 /* ObjectValue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ObjectValue.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4B5D /* CustomOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomOperators.swift; sourceTree = ""; }; C3D4E5F60718293A4B5C03D /* EntriesOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EntriesOperators.swift; sourceTree = ""; }; D4E5F60718293A4B5C03D01 /* CaseOperators.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CaseOperators.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Sources/LocalRules/DimensionResolver.swift b/Sources/LocalRules/DimensionResolver.swift index d5b58d030e..5b0784a97d 100644 --- a/Sources/LocalRules/DimensionResolver.swift +++ b/Sources/LocalRules/DimensionResolver.swift @@ -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)) diff --git a/Sources/RulesEngine/CustomOperators/EntriesOperators.swift b/Sources/RulesEngine/CustomOperators/EntriesOperators.swift index 5d53ceef39..2c129f5763 100644 --- a/Sources/RulesEngine/CustomOperators/EntriesOperators.swift +++ b/Sources/RulesEngine/CustomOperators/EntriesOperators.swift @@ -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( diff --git a/Sources/RulesEngine/CustomOperators/LetOperator.swift b/Sources/RulesEngine/CustomOperators/LetOperator.swift index 0e95ff2f05..d70c20475d 100644 --- a/Sources/RulesEngine/CustomOperators/LetOperator.swift +++ b/Sources/RulesEngine/CustomOperators/LetOperator.swift @@ -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() { diff --git a/Sources/RulesEngine/Evaluator.swift b/Sources/RulesEngine/Evaluator.swift index fce925610c..72d75a5a83 100644 --- a/Sources/RulesEngine/Evaluator.swift +++ b/Sources/RulesEngine/Evaluator.swift @@ -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 diff --git a/Sources/RulesEngine/ObjectValue.swift b/Sources/RulesEngine/ObjectValue.swift new file mode 100644 index 0000000000..53b3f8ab9c --- /dev/null +++ b/Sources/RulesEngine/ObjectValue.swift @@ -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) + } + } +} diff --git a/Sources/RulesEngine/RulesEngineEvaluate.swift b/Sources/RulesEngine/RulesEngineEvaluate.swift index 4ecec49976..bf7f1be144 100644 --- a/Sources/RulesEngine/RulesEngineEvaluate.swift +++ b/Sources/RulesEngine/RulesEngineEvaluate.swift @@ -18,7 +18,7 @@ extension RulesEngine { /// an `EvaluationError` when parsing or evaluation fails. static func evaluate( predicate: String, - variables: [String: Value] + variables: ObjectValue ) -> Result { do { let predicateValue = try Value.fromJSONString(predicate) diff --git a/Sources/RulesEngine/Scope.swift b/Sources/RulesEngine/Scope.swift index f26855399a..ec8d86404b 100644 --- a/Sources/RulesEngine/Scope.swift +++ b/Sources/RulesEngine/Scope.swift @@ -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 diff --git a/Sources/RulesEngine/Value+JSON.swift b/Sources/RulesEngine/Value+JSON.swift index ca8e8d9966..504ca4bf28 100644 --- a/Sources/RulesEngine/Value+JSON.swift +++ b/Sources/RulesEngine/Value+JSON.swift @@ -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) diff --git a/Sources/RulesEngine/Value.swift b/Sources/RulesEngine/Value.swift index c9d884e052..2669d1f9ea 100644 --- a/Sources/RulesEngine/Value.swift +++ b/Sources/RulesEngine/Value.swift @@ -30,7 +30,7 @@ extension RulesEngine { /// A JSON array. case array([Value]) /// A JSON object. - case object([String: Value]) + case object(ObjectValue) } } diff --git a/Tests/UnitTests/LocalRules/DimensionScopeTests.swift b/Tests/UnitTests/LocalRules/DimensionScopeTests.swift index 2b68341510..9e1fb969c0 100644 --- a/Tests/UnitTests/LocalRules/DimensionScopeTests.swift +++ b/Tests/UnitTests/LocalRules/DimensionScopeTests.swift @@ -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"), diff --git a/Tests/UnitTests/RulesEngine/Helpers/PredicateConformanceRunner.swift b/Tests/UnitTests/RulesEngine/Helpers/PredicateConformanceRunner.swift index 47de8e6ad6..db57412605 100644 --- a/Tests/UnitTests/RulesEngine/Helpers/PredicateConformanceRunner.swift +++ b/Tests/UnitTests/RulesEngine/Helpers/PredicateConformanceRunner.swift @@ -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 { diff --git a/Tests/UnitTests/RulesEngine/Helpers/Value+Decodable.swift b/Tests/UnitTests/RulesEngine/Helpers/Value+Decodable.swift index 1a6935d07c..c0d1215d28 100644 --- a/Tests/UnitTests/RulesEngine/Helpers/Value+Decodable.swift +++ b/Tests/UnitTests/RulesEngine/Helpers/Value+Decodable.swift @@ -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( diff --git a/Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift b/Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift index c53ce8d729..093ad60b63 100644 --- a/Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift +++ b/Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift @@ -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)" diff --git a/Tests/UnitTests/RulesEngine/PredicateFixtures/rc_entries.json b/Tests/UnitTests/RulesEngine/PredicateFixtures/rc_entries.json index b2ca8068e8..65001e931d 100644 --- a/Tests/UnitTests/RulesEngine/PredicateFixtures/rc_entries.json +++ b/Tests/UnitTests/RulesEngine/PredicateFixtures/rc_entries.json @@ -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 } ] } diff --git a/Tests/UnitTests/RulesEngine/PredicateFixtures/var.json b/Tests/UnitTests/RulesEngine/PredicateFixtures/var.json index b2032e8489..958c877999 100644 --- a/Tests/UnitTests/RulesEngine/PredicateFixtures/var.json +++ b/Tests/UnitTests/RulesEngine/PredicateFixtures/var.json @@ -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 } ] }