Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Observation notification not triggering on nested properties #8298

Merged
merged 2 commits into from
Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ x.y.z Release notes (yyyy-MM-dd)
* None.

### Fixed
* <How to hit and notice issue? what was the impact?> ([#????](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).

<!-- ### Breaking Changes - ONLY INCLUDE FOR NEW MAJOR version -->

Expand Down
41 changes: 38 additions & 3 deletions RealmSwift/Projection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Person> {
@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.
Expand Down Expand Up @@ -321,15 +344,27 @@ extension ProjectionObservable {

var projectedChanges = [PropertyChange]()
for i in 0..<newValues.count {
for property in schema.filter({ $0.originPropertyKeyPathString == names[i] }) {
let filter: (ProjectionProperty) -> 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))
}
}

Expand Down
88 changes: 86 additions & 2 deletions RealmSwift/Tests/ProjectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommonPerson>
@Persisted var reviews: List<String>
@Persisted var money: Decimal128
Expand All @@ -214,6 +228,8 @@ public final class PersonProjection: Projection<CommonPerson> {
@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<String>
}

Expand Down Expand Up @@ -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": "[email protected]"],
"money": Decimal128("2.22")])
let dt = realm.create(CommonPerson.self, value: ["firstName": "Daenerys",
"lastName": "Targaryen",
Expand Down Expand Up @@ -493,7 +512,7 @@ class ProjectionTests: TestCase {

func testDescription() {
let actual = populatedRealm().objects(PersonProjection.self).filter("lastName == 'Snow'").first!.description
let expected = "PersonProjection<CommonPerson> <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<String> \\{\n\t\\[0\\] Daenerys\n\\};\n\\}"
let expected = "PersonProjection<CommonPerson> <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\\(\"[email protected]\"\\);\n\tmobile\\(\\\\.extras.phone.mobile.number\\) = Optional\\(\"555-555-555\"\\);\n\tfirstFriendsName\\(\\\\.friends\\) = ProjectedCollection<String> \\{\n\t\\[0\\] Daenerys\n\\};\n\\}"
assertMatches(actual, expected)
}

Expand Down Expand Up @@ -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, "[email protected]")
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 = "[email protected]"
}
}
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]?,
Expand Down