From 7c3aadfd7e9a46698c65387009c4b1f8a31f091b Mon Sep 17 00:00:00 2001 From: Diana Perez Afanador Date: Tue, 11 Jul 2023 19:44:16 +0200 Subject: [PATCH 1/2] Fix Observation notification not triggering on nested properties --- CHANGELOG.md | 4 +- RealmSwift/Projection.swift | 35 ++++++++- RealmSwift/Tests/ProjectionTests.swift | 88 ++++++++++++++++++++++- Test.xcworkspace/contents.xcworkspacedata | 10 +++ 4 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 Test.xcworkspace/contents.xcworkspacedata diff --git a/CHANGELOG.md b/CHANGELOG.md index 399883ebb1..b56774fb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ x.y.z Release notes (yyyy-MM-dd) * None. ### Fixed -* ([#????](https://github.com/realm/realm-swift/issues/????), since v?.?.?) -* None. +* Fix nested properties observation on a `Projections` not notifying when there is a property change. + ([#8276](https://github.com/realm/realm-swift/issues/8276), since v10.34.0). diff --git a/RealmSwift/Projection.swift b/RealmSwift/Projection.swift index b3afd977b1..badca023cc 100644 --- a/RealmSwift/Projection.swift +++ b/RealmSwift/Projection.swift @@ -269,6 +269,29 @@ extension ProjectionObservable { - warning: This method cannot be called during a write transaction, or when the containing Realm is read-only. + - warning: For projected properties where the original property has the same root property name, + this will trigger a `PropertyChange` for each of the Projected properties even though + the change only corresponds to one of them. + For the following `Projection` object + ```swift + class PersonProjection: Projection { + @Projected(\Person.firstName) var name + @Projected(\Person.address.country) originCountry + @Projected(\Person.address.phone.number) mobile + } + + let token = projectedPerson.observe { changes in + if case .change(_, let propertyChanges) = changes { + propertyChanges[0].newValue as? String, "Winterfell" // Will notify the new value + propertyChanges[1].newValue as? String, "555-555-555" // Will notify with the current value, which hasn't change. + } + }) + + try realm.write { + person.address.country = "Winterfell" + } + ``` + - parameter keyPaths: Only properties contained in the key paths array will trigger the block when they are modified. If `nil`, notifications will be delivered for any projected property change on the object. @@ -321,15 +344,21 @@ extension ProjectionObservable { var projectedChanges = [PropertyChange]() for i in 0.. @Persisted var reviews: List @Persisted var money: Decimal128 @@ -214,6 +228,8 @@ public final class PersonProjection: Projection { @Projected(\CommonPerson.lastName.localizedUppercase) var lastNameCaps @Projected(\CommonPerson.birthday.timeIntervalSince1970) var birthdayAsEpochtime @Projected(\CommonPerson.address?.city) var homeCity + @Projected(\CommonPerson.extras?.email) var email + @Projected(\CommonPerson.extras?.phone?.mobile?.number) var mobile @Projected(\CommonPerson.friends.projectTo.firstName) var firstFriendsName: ProjectedCollection } @@ -416,7 +432,10 @@ class ProjectionTests: TestCase { let js = realm.create(CommonPerson.self, value: ["firstName": "John", "lastName": "Snow", "birthday": Date(timeIntervalSince1970: 10), - "address": ["Winterfell", "Kingdom in the North"], + "address": [ + "city": "Winterfell", + "country": "Kingdom in the North"], + "extras": ["phone": ["mobile": ["number": "555-555-555"]], "email": "john@doe.com"], "money": Decimal128("2.22")]) let dt = realm.create(CommonPerson.self, value: ["firstName": "Daenerys", "lastName": "Targaryen", @@ -493,7 +512,7 @@ class ProjectionTests: TestCase { func testDescription() { let actual = populatedRealm().objects(PersonProjection.self).filter("lastName == 'Snow'").first!.description - let expected = "PersonProjection <0x[0-9a-f]+> \\{\n\t\tfirstName\\(\\\\.firstName\\) = John;\n\tlastNameCaps\\(\\\\.lastName\\) = SNOW;\n\tbirthdayAsEpochtime\\(\\\\.birthday\\) = 10.0;\n\thomeCity\\(\\\\.address.city\\) = Optional\\(\"Winterfell\"\\);\n\tfirstFriendsName\\(\\\\.friends\\) = ProjectedCollection \\{\n\t\\[0\\] Daenerys\n\\};\n\\}" + let expected = "PersonProjection <0x[0-9a-f]+> \\{\n\t\tfirstName\\(\\\\.firstName\\) = John;\n\tlastNameCaps\\(\\\\.lastName\\) = SNOW;\n\tbirthdayAsEpochtime\\(\\\\.birthday\\) = 10.0;\n\thomeCity\\(\\\\.address.city\\) = Optional\\(\"Winterfell\"\\);\n\temail\\(\\\\.extras.email\\) = Optional\\(\"john@doe.com\"\\);\n\tmobile\\(\\\\.extras.phone.mobile.number\\) = Optional\\(\"555-555-555\"\\);\n\tfirstFriendsName\\(\\\\.friends\\) = ProjectedCollection \\{\n\t\\[0\\] Daenerys\n\\};\n\\}" assertMatches(actual, expected) } @@ -945,6 +964,71 @@ class ProjectionTests: TestCase { token.invalidate() } + func testObserveNestedProjection() { + let realm = populatedRealm() + let johnProjection = realm.objects(PersonProjection.self).first! + + var ex = expectation(description: "testProjectionNotificationNestedWithKeyPath") + let token = johnProjection.observe(keyPaths: [\PersonProjection.mobile]) { changes in + if case .change(_, let propertyChange) = changes { + XCTAssertEqual(propertyChange[0].name, "mobile") + XCTAssertEqual((propertyChange[0].newValue as? String), "529-345-678") + ex.fulfill() + } else { + XCTFail("expected .change, got \(changes)") + } + } + dispatchSyncNewThread { + let realm = self.realmWithTestPath() + try! realm.write { + let johnObject = realm.objects(CommonPerson.self).filter("lastName == 'Snow'").first! + johnObject.extras?.phone?.mobile?.number = "529-345-678" + } + } + waitForExpectations(timeout: 2.0, handler: nil) + token.invalidate() + + ex = expectation(description: "testProjectionNotificationNested") + let token2 = johnProjection.observe { changes in + if case .change(_, let propertyChange) = changes { + XCTAssertEqual(propertyChange[0].name, "email") + XCTAssertEqual(propertyChange[0].newValue as? String, "joe@realm.com") + ex.fulfill() + } else { + XCTFail("expected .change, got \(changes)") + } + } + dispatchSyncNewThread { + let realm = self.realmWithTestPath() + try! realm.write { + let johnObject = realm.objects(CommonPerson.self).filter("lastName == 'Snow'").first! + johnObject.extras?.email = "joe@realm.com" + } + } + waitForExpectations(timeout: 2.0, handler: nil) + token2.invalidate() + + ex = expectation(description: "testProjectionNotificationEmbeddedNested") + let token3 = johnProjection.observe { changes in + if case .change(_, let propertyChange) = changes { + XCTAssertEqual(propertyChange[0].name, "homeCity") + XCTAssertEqual(propertyChange[0].newValue as? String, "Barranquilla") + ex.fulfill() + } else { + XCTFail("expected .change, got \(changes)") + } + } + dispatchSyncNewThread { + let realm = self.realmWithTestPath() + try! realm.write { + let johnObject = realm.objects(CommonPerson.self).filter("lastName == 'Snow'").first! + johnObject.address?.city = "Barranquilla" + } + } + waitForExpectations(timeout: 2.0, handler: nil) + token3.invalidate() + } + var changeDictionary: [NSKeyValueChangeKey: Any]? override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, diff --git a/Test.xcworkspace/contents.xcworkspacedata b/Test.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..ba96ce5650 --- /dev/null +++ b/Test.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + From e7f672cf409ee3b691bde2014b6ad2ccf2124d5b Mon Sep 17 00:00:00 2001 From: Diana Perez Afanador Date: Fri, 21 Jul 2023 12:20:11 +0200 Subject: [PATCH 2/2] Solve PR comments --- RealmSwift/Projection.swift | 14 ++++++++++---- Test.xcworkspace/contents.xcworkspacedata | 10 ---------- 2 files changed, 10 insertions(+), 14 deletions(-) delete mode 100644 Test.xcworkspace/contents.xcworkspacedata diff --git a/RealmSwift/Projection.swift b/RealmSwift/Projection.swift index badca023cc..c43854c7ee 100644 --- a/RealmSwift/Projection.swift +++ b/RealmSwift/Projection.swift @@ -344,12 +344,18 @@ extension ProjectionObservable { var projectedChanges = [PropertyChange]() for i in 0.. Bool = { prop in + if prop.originPropertyKeyPathString.components(separatedBy: ".").first != names[i] { + return false } + guard let keyPaths, !keyPaths.isEmpty else { + return true + } + // This will allow us to notify `PropertyChange`s associated only to the keyPaths passed by the user, instead of any Property which has the same root as the notified one. - return prop.originPropertyKeyPathString.components(separatedBy: ".").first == names[i] && keyPaths.contains(prop.originPropertyKeyPathString) }) { + return keyPaths.contains(prop.originPropertyKeyPathString) + } + for property in schema.filter(filter) { // If the root is marked as modified this will build a `PropertyChange` for each of the Projection properties with the same original root, even if there is no change on their value. var changeOldValue: Any? if oldValues != nil { diff --git a/Test.xcworkspace/contents.xcworkspacedata b/Test.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index ba96ce5650..0000000000 --- a/Test.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - -