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..c43854c7ee 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,27 @@ 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 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 { changeOldValue = unmanagedRoot![keyPath: property.projectedKeyPath] } - let changeNewValue = object[keyPath: property.projectedKeyPath] + let changedNewValue = object[keyPath: property.projectedKeyPath] projectedChanges.append(.init(name: property.label, oldValue: changeOldValue, - newValue: changeNewValue)) + newValue: changedNewValue)) } } diff --git a/RealmSwift/Tests/ProjectionTests.swift b/RealmSwift/Tests/ProjectionTests.swift index 5c30d3dd8b..e3004966bb 100644 --- a/RealmSwift/Tests/ProjectionTests.swift +++ b/RealmSwift/Tests/ProjectionTests.swift @@ -199,11 +199,25 @@ public class AddressSwift: EmbeddedObject { @Persisted var country = "" } +public class ExtraInfo: Object { + @Persisted var phone: PhoneInfo? + @Persisted var email: String? +} + +public class PhoneInfo: Object { + @Persisted var mobile: Mobile? +} + +public class Mobile: EmbeddedObject { + @Persisted var number: String = "" +} + public class CommonPerson: Object { @Persisted var firstName: String @Persisted var lastName = "" @Persisted var birthday: Date @Persisted var address: AddressSwift? + @Persisted var extras: ExtraInfo? @Persisted public var friends: List @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]?,