diff --git a/integration-tests/tests/src/tests/observable.ts b/integration-tests/tests/src/tests/observable.ts index 1fbe8c2d02..2b02dd407d 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -420,7 +420,15 @@ describe("Observable", () => { }); describe("Object", () => { - type Person = { name: string; age: number | undefined; friends: Realm.List }; + type EmbeddedAddress = { street: string; city: string }; + type Person = { + name: string; + age: number | undefined; + friends: Realm.List; + bestFriend: Person | null; + embeddedAddress: EmbeddedAddress | null; + }; + openRealmBeforeEach({ schema: [ { @@ -429,6 +437,16 @@ describe("Observable", () => { name: "string", age: "int?", friends: "Person[]", + bestFriend: "Person?", + embeddedAddress: "EmbeddedAddress?", + }, + }, + { + name: "EmbeddedAddress", + embedded: true, + properties: { + street: "string", + city: "string", }, }, ], @@ -467,7 +485,7 @@ describe("Observable", () => { await expectObjectNotifications(this.object, ["name"], [EMPTY_OBJECT_CHANGESET]); }); - it("calls listener", async function (this: RealmObjectContext) { + it("calls listener when primitive property is updated", async function (this: RealmObjectContext) { await expectObjectNotifications(this.object, undefined, [ EMPTY_OBJECT_CHANGESET, () => { @@ -479,6 +497,60 @@ describe("Observable", () => { ]); }); + it("does not call listener when non-embedded object is updated", async function (this: RealmObjectContext) { + const bob = this.realm.objects("Person")[1]; + expect(bob.name).equals("Bob"); + + await expectObjectNotifications(this.object, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Setting the link should trigger the listener. + () => { + this.realm.write(() => { + this.object.bestFriend = bob; + }); + expect(this.object.bestFriend?.name).equals("Bob"); + }, + { deleted: false, changedProperties: ["bestFriend"] }, + // Updating the link should NOT trigger the listener. + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.bestFriend!.name = "Bobby"; + }); + expect(this.object.bestFriend?.name).equals("Bobby"); + }, + ]); + }); + + it("does not call listener when embedded object is updated", async function (this: RealmObjectContext) { + await expectObjectNotifications(this.object, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Setting the link should trigger the listener. + () => { + this.realm.write(() => { + this.object.embeddedAddress = { street: "1633 Broadway", city: "New York" }; + }); + expect(this.object.embeddedAddress).deep.equals({ street: "1633 Broadway", city: "New York" }); + }, + { deleted: false, changedProperties: ["embeddedAddress"] }, + // Updating the link should NOT trigger the listener. + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.street = "88 Kearny Street"; + }); + expect(this.object.embeddedAddress?.street).equals("88 Kearny Street"); + }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.city = "San Francisco"; + }); + expect(this.object.embeddedAddress?.city).equals("San Francisco"); + }, + ]); + }); + it("removes listeners", async function (this: RealmObjectContext) { await expectListenerRemoval({ addListener: (listener) => this.object.addListener(listener), @@ -504,7 +576,7 @@ describe("Observable", () => { }); describe("key-path filtered", () => { - it("calls listener only on relevant changes", async function (this: RealmObjectContext) { + it("calls listener when specified primitive is updated", async function (this: RealmObjectContext) { await expectObjectNotifications( this.object, ["name"], @@ -537,7 +609,9 @@ describe("Observable", () => { }, ], ); + }); + it("calls listener when specified list of links is updated", async function (this: RealmObjectContext) { await expectObjectNotifications( this.object, ["friends"], @@ -563,7 +637,9 @@ describe("Observable", () => { }, ], ); + }); + it("calls listener when specified primitive on link in list is updated", async function (this: RealmObjectContext) { await expectObjectNotifications( this.object, ["friends.name"], @@ -578,6 +654,15 @@ describe("Observable", () => { deleted: false, changedProperties: ["friends"], }, + () => { + this.realm.write(() => { + this.object.friends[1].name = "Charles"; + }); + }, + { + deleted: false, + changedProperties: ["friends"], + }, // Perform a couple of changes that shouldn't trigger () => { this.realm.write(() => { @@ -589,7 +674,9 @@ describe("Observable", () => { }, ], ); + }); + it("calls listener when one-level wildcard is specified and top-level property is updated", async function (this: RealmObjectContext) { await expectObjectNotifications( this.object, ["*"], @@ -628,6 +715,67 @@ describe("Observable", () => { ); }); + it("calls listener when two-level wildcard is specified and non-embedded object is updated", async function (this: RealmObjectContext) { + const bob = this.realm.objects("Person")[1]; + expect(bob.name).equals("Bob"); + + await expectObjectNotifications( + this.object, + ["*.*"], + [ + EMPTY_OBJECT_CHANGESET, + () => { + this.realm.write(() => { + this.object.bestFriend = bob; + }); + expect(this.object.bestFriend?.name).equals("Bob"); + }, + { deleted: false, changedProperties: ["bestFriend"] }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.bestFriend!.name = "Bobby"; + }); + expect(this.object.bestFriend?.name).equals("Bobby"); + }, + { deleted: false, changedProperties: ["bestFriend", "friends"] }, + ], + ); + }); + + it("calls listener when two-level wildcard is specified and embedded object is updated", async function (this: RealmObjectContext) { + await expectObjectNotifications( + this.object, + ["*.*"], + [ + EMPTY_OBJECT_CHANGESET, + () => { + this.realm.write(() => { + this.object.embeddedAddress = { street: "1633 Broadway", city: "New York" }; + }); + expect(this.object.embeddedAddress).deep.equals({ street: "1633 Broadway", city: "New York" }); + }, + { deleted: false, changedProperties: ["embeddedAddress"] }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.street = "88 Kearny Street"; + }); + expect(this.object.embeddedAddress?.street).equals("88 Kearny Street"); + }, + { deleted: false, changedProperties: ["embeddedAddress"] }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.city = "San Francisco"; + }); + expect(this.object.embeddedAddress?.city).equals("San Francisco"); + }, + { deleted: false, changedProperties: ["embeddedAddress"] }, + ], + ); + }); + it("combines key-paths when delivering notifications", async function (this: RealmObjectContext) { const completion1 = expectObjectNotifications( this.object, @@ -675,8 +823,15 @@ describe("Observable", () => { }); describe("Results", () => { - type Person = { name: string; age: number | undefined; friends: Realm.List }; - // change: with / without key-paths + type EmbeddedAddress = { street: string; city: string }; + type Person = { + name: string; + age: number | undefined; + friends: Realm.List; + bestFriend: Person | null; + embeddedAddress: EmbeddedAddress | null; + }; + openRealmBeforeEach({ schema: [ { @@ -685,6 +840,16 @@ describe("Observable", () => { name: "string", age: "int?", friends: "Person[]", + bestFriend: "Person?", + embeddedAddress: "EmbeddedAddress?", + }, + }, + { + name: "EmbeddedAddress", + embedded: true, + properties: { + street: "string", + city: "string", }, }, ], @@ -724,7 +889,7 @@ describe("Observable", () => { await expectCollectionNotifications(this.realm.objects("Person"), ["name"], [EMPTY_COLLECTION_CHANGESET]); }); - it("calls listener", async function (this: RealmObjectContext) { + it("calls listener when primitive is updated", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person"); await expectCollectionNotifications(collection, undefined, [ EMPTY_COLLECTION_CHANGESET, @@ -742,6 +907,87 @@ describe("Observable", () => { ]); }); + it("calls listener when non-embedded object is updated", async function (this: RealmObjectContext) { + const collection = this.realm.objects("Person"); + const bob = collection[1]; + expect(bob.name).equals("Bob"); + + await expectCollectionNotifications(collection, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.object.bestFriend = bob; + }); + expect(this.object.bestFriend?.name).equals("Bob"); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.bestFriend!.name = "Bobby"; + }); + expect(this.object.bestFriend?.name).equals("Bobby"); + }, + { + deletions: [], + insertions: [], + newModifications: [0, 1], + oldModifications: [0, 1], + }, + ]); + }); + + it("calls listener when embedded object is updated", async function (this: RealmObjectContext) { + const collection = this.realm.objects("Person"); + + await expectCollectionNotifications(collection, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.object.embeddedAddress = { street: "1633 Broadway", city: "New York" }; + }); + expect(this.object.embeddedAddress).deep.equals({ street: "1633 Broadway", city: "New York" }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.street = "88 Kearny Street"; + }); + expect(this.object.embeddedAddress?.street).equals("88 Kearny Street"); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + () => { + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.object.embeddedAddress!.city = "San Francisco"; + }); + expect(this.object.embeddedAddress?.city).equals("San Francisco"); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + ]); + }); + it("removes listeners", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person"); await expectListenerRemoval({ @@ -769,7 +1015,7 @@ describe("Observable", () => { }); describe("key-path filtered", () => { - it("fires on relevant changes to a primitive", async function (this: RealmObjectContext) { + it("calls listener when specified primitive is updated", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person").filtered("name = $0 OR age = 42", "Alice"); await expectCollectionNotifications( collection, @@ -832,7 +1078,7 @@ describe("Observable", () => { ); }); - it("fires on relevant changes to a list", async function (this: RealmObjectContext) { + it("calls listener when specified list of links is updated", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person").filtered("name = $0 OR age = 42", "Alice"); await expectCollectionNotifications( collection, @@ -863,7 +1109,7 @@ describe("Observable", () => { ); }); - it("fires on relevant changes to a primitive of a list", async function (this: RealmObjectContext) { + it("calls listener when specified primitive on link in list is updated", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person").filtered("name = $0 OR age = 42", "Alice"); await expectCollectionNotifications( collection, @@ -894,7 +1140,7 @@ describe("Observable", () => { ); }); - it("fires on relevant changes to a wildcard", async function (this: RealmObjectContext) { + it("calls listener when one-level wildcard is specified and top-level property is updated", async function (this: RealmObjectContext) { const collection = this.realm.objects("Person").filtered("name = $0 OR age = 42", "Alice"); await expectCollectionNotifications( collection, diff --git a/packages/realm/src/Collection.ts b/packages/realm/src/Collection.ts index 01c548dcd4..12d6b6c553 100644 --- a/packages/realm/src/Collection.ts +++ b/packages/realm/src/Collection.ts @@ -158,6 +158,9 @@ export abstract class Collection< * @note `deletions` and `oldModifications` report the indices in the collection before the change happened, * while `insertions` and `newModifications` report the indices into the new version of the collection. * @throws A {@link TypeAssertionError} if `callback` is not a function. + * @note + * Adding the listener is an asynchronous operation, so the callback is invoked the first time to notify the caller when the listener has been added. + * Thus, when the callback is invoked the first time it will contain empty arrays for each property in the `changes` object. * @example * wines.addListener((collection, changes) => { * // collection === wines @@ -171,8 +174,6 @@ export abstract class Collection< * wines.addListener((collection, changes) => { * console.log("A wine's brand might have changed"); * }, ["brand"]); - * @note Adding the listener is an asynchronous operation, so the callback is invoked the first time to notify the caller when the listener has been added. - * Thus, when the callback is invoked the first time it will contain empty arrays for each property in the `changes` object. */ addListener(callback: ChangeCallbackType, keyPaths?: string | string[]): void { assert.function(callback, "callback"); diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index 6851b1b01a..4e96620f0e 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -21,12 +21,14 @@ import { BSON, CanonicalObjectSchema, ClassHelpers, + type Collection, Constructor, DefaultObject, Dictionary, JSONCacheMap, ObjectChangeCallback, ObjectListeners, + ObjectSchema, OmittedRealmTypes, OrderedCollection, Realm, @@ -494,6 +496,11 @@ export class RealmObject { * // obj === wine @@ -507,8 +514,6 @@ export class RealmObject { * console.log("The wine got deleted or its brand might have changed"); * }, ["brand"]) - * @note Adding the listener is an asynchronous operation, so the callback is invoked the first time to notify the caller when the listener has been added. - * Thus, when the callback is invoked the first time it will contain empty array for `changes.changedProperties`. */ addListener(callback: ObjectChangeCallback, keyPaths?: string | string[]): void { assert.function(callback);