diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb79a49c8..a074f2a7fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### Fixed * Fixed updating helpers (the `ClassMap`) used by `Realm` before notifying schema change listeners when the schema is changed during runtime. ([#5574](https://github.com/realm/realm-js/issues/5574)) -* Fix crashes on refresh of the React Native application. ([#5904](https://github.com/realm/realm-js/issues/5904)) +* Fixed crashes on refresh of the React Native application. ([#5904](https://github.com/realm/realm-js/issues/5904)) +* Fixed applying `UpdateMode` recursively to all objects when passed to `Realm.create()`. ([#5933](https://github.com/realm/realm-js/issues/5933)) ### Compatibility * React Native >= v0.71.4 diff --git a/integration-tests/tests/src/tests/objects.ts b/integration-tests/tests/src/tests/objects.ts index 0478592863..61a10e2efc 100644 --- a/integration-tests/tests/src/tests/objects.ts +++ b/integration-tests/tests/src/tests/objects.ts @@ -183,6 +183,18 @@ const TestObjectSchema = { }, }; +const CollectionSchema: Realm.ObjectSchema = { + name: "CollectionObject", + primaryKey: "_id", + properties: { + _id: "objectId", + name: "string", + list: "CollectionObject[]", + dictionary: "CollectionObject{}", + set: "CollectionObject<>", + }, +}; + const AllTypesSchema = { name: "AllTypesObject", properties: { @@ -285,6 +297,14 @@ interface INonPersistentTestObject extends ITestObject { ignored: boolean; } +interface ICollectionObject { + _id: Realm.BSON.ObjectId; + name: string; + list: Realm.List; + dictionary: Realm.Dictionary; + set: Realm.Set; +} + interface IAllTypes { boolCol: boolean; intCol: number; @@ -517,7 +537,7 @@ describe("Realm.Object", () => { }); describe("with primary key", () => { - openRealmBeforeEach({ schema: [PersonWithId] }); + openRealmBeforeEach({ schema: [PersonWithId, CollectionSchema] }); it("can be fetched with objectForPrimaryKey", function (this: Mocha.Context & RealmContext) { const _id = new Realm.BSON.ObjectId(); @@ -586,6 +606,146 @@ describe("Realm.Object", () => { const persons = this.realm.objects(PersonWithId); expect(persons.length).equals(1); }); + + describe("applying 'UpdateMode' recursively", () => { + let aliceId: Realm.BSON.ObjectId; + let bobId: Realm.BSON.ObjectId; + let maxId: Realm.BSON.ObjectId; + + beforeEach(function (this: RealmContext & Mocha.Context) { + aliceId = new Realm.BSON.ObjectId(); + bobId = new Realm.BSON.ObjectId(); + maxId = new Realm.BSON.ObjectId(); + + // Create two mutual friends (Alice and Bob) and one + // that will be added as a friend later (Max). + this.realm.write(() => { + const alice = this.realm.create(CollectionSchema.name, { + _id: aliceId, + name: "Alice", + }); + const bob = this.realm.create(CollectionSchema.name, { + _id: bobId, + name: "Bob", + }); + const max = this.realm.create(CollectionSchema.name, { + _id: maxId, + name: "Max", + }); + // Make them mutual friends. + alice.list.push(bob); + bob.list.push(alice); + }); + }); + + it("can be updated recursively in lists", function (this: Mocha.Context & RealmContext) { + const alice = this.realm.write(() => { + return this.realm.create( + CollectionSchema.name, + { + _id: aliceId, + name: "UpdatedAlice", + list: [ + { + _id: bobId, + name: "UpdatedBob", + list: [ + { + _id: maxId, + name: "UpdatedMax", + }, + ], + }, + ], + }, + Realm.UpdateMode.All, + ); + }); + + expect(alice._id.equals(aliceId)).to.be.true; + expect(alice.name).to.equal("UpdatedAlice"); + + const bob = alice.list[0]; + expect(bob._id.equals(bobId)).to.be.true; + expect(bob.name).to.equal("UpdatedBob"); + + const max = bob.list[0]; + expect(max._id.equals(maxId)).to.be.true; + expect(max.name).to.equal("UpdatedMax"); + }); + + it("can be updated recursively in dictionaries", function (this: Mocha.Context & RealmContext) { + const alice = this.realm.write(() => { + return this.realm.create( + CollectionSchema.name, + { + _id: aliceId, + name: "UpdatedAlice", + dictionary: { + bob: { + _id: bobId, + name: "UpdatedBob", + dictionary: { + max: { + _id: maxId, + name: "UpdatedMax", + }, + }, + }, + }, + }, + Realm.UpdateMode.All, + ); + }); + + expect(alice._id.equals(aliceId)).to.be.true; + expect(alice.name).to.equal("UpdatedAlice"); + + const { bob } = alice.dictionary; + expect(bob._id.equals(bobId)).to.be.true; + expect(bob.name).to.equal("UpdatedBob"); + + const { max } = bob.dictionary; + expect(max._id.equals(maxId)).to.be.true; + expect(max.name).to.equal("UpdatedMax"); + }); + + it("can be updated recursively in sets", function (this: Mocha.Context & RealmContext) { + const alice = this.realm.write(() => { + return this.realm.create( + CollectionSchema.name, + { + _id: aliceId, + name: "UpdatedAlice", + set: [ + { + _id: bobId, + name: "UpdatedBob", + set: [ + { + _id: maxId, + name: "UpdatedMax", + }, + ], + }, + ], + }, + Realm.UpdateMode.All, + ); + }); + + expect(alice._id.equals(aliceId)).to.be.true; + expect(alice.name).to.equal("UpdatedAlice"); + + const bob = alice.set[0]; + expect(bob._id.equals(bobId)).to.be.true; + expect(bob.name).to.equal("UpdatedBob"); + + const max = bob.set[0]; + expect(max._id.equals(maxId)).to.be.true; + expect(max.name).to.equal("UpdatedMax"); + }); + }); }); }); diff --git a/packages/realm/src/Dictionary.ts b/packages/realm/src/Dictionary.ts index 1843e35b33..232c47d247 100644 --- a/packages/realm/src/Dictionary.ts +++ b/packages/realm/src/Dictionary.ts @@ -57,7 +57,7 @@ const PROXY_HANDLER: ProxyHandler = { if (typeof prop === "string") { const internal = target[INTERNAL]; const toBinding = target[HELPERS].toBinding; - internal.insertAny(prop, toBinding(value, undefined)); + internal.insertAny(prop, toBinding(value)); return true; } else { assert(typeof prop !== "symbol", "Symbols cannot be used as keys of a dictionary"); @@ -280,7 +280,7 @@ export class Dictionary extends Collection extends OrderedCollection implements Partially } = this; assert.inTransaction(realm); // TODO: Consider a more performant way to determine if the list is embedded - internal.setAny(index, toBinding(value, isEmbedded ? () => [internal.insertEmbedded(index), true] : undefined)); + internal.setAny( + index, + toBinding(value, isEmbedded ? { createObj: () => [internal.insertEmbedded(index), true] } : undefined), + ); } get length(): number { @@ -142,7 +145,7 @@ export class List extends OrderedCollection implements Partially const index = start + offset; if (isEmbedded) { // Simply transforming to binding will insert the embedded object - toBinding(item, () => [internal.insertEmbedded(index), true]); + toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); } else { internal.insertAny(index, toBinding(item)); } @@ -186,7 +189,7 @@ export class List extends OrderedCollection implements Partially for (const [index, item] of items.entries()) { if (isEmbedded) { // Simply transforming to binding will insert the embedded object - toBinding(item, () => [internal.insertEmbedded(index), true]); + toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); } else { internal.insertAny(index, toBinding(item)); } @@ -270,7 +273,7 @@ export class List extends OrderedCollection implements Partially const index = start + offset; if (isEmbedded) { // Simply transforming to binding will insert the embedded object - toBinding(item, () => [internal.insertEmbedded(index), true]); + toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); } else { internal.insertAny(index, toBinding(item)); } diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index deeb0cc2bc..7cbea6bc69 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -186,6 +186,8 @@ export class RealmObject { // Asking for the toBinding will create the object and link it to the parent in one operation // no need to actually set the value on the `obj` - toBinding(value, () => [obj.createAndSetLinkedObject(columnKey), true]); + toBinding(value, { createObj: () => [obj.createAndSetLinkedObject(columnKey), true] }); }; } @@ -212,9 +212,9 @@ const ACCESSOR_FACTORIES: Partial> for (const value of values) { try { if (embedded) { - itemToBinding(value, () => [internal.insertEmbedded(index), true]); + itemToBinding(value, { createObj: () => [internal.insertEmbedded(index), true] }); } else { - bindingValues.push(itemToBinding(value, undefined)); + bindingValues.push(itemToBinding(value)); } } catch (err) { if (err instanceof TypeAssertionError) { @@ -260,9 +260,9 @@ const ACCESSOR_FACTORIES: Partial> for (const [k, v] of Object.entries(value)) { try { if (embedded) { - itemHelpers.toBinding(v, () => [internal.insertEmbedded(k), true]); + itemHelpers.toBinding(v, { createObj: () => [internal.insertEmbedded(k), true] }); } else { - internal.insertAny(k, itemHelpers.toBinding(v, undefined)); + internal.insertAny(k, itemHelpers.toBinding(v)); } } catch (err) { if (err instanceof TypeAssertionError) { diff --git a/packages/realm/src/Realm.ts b/packages/realm/src/Realm.ts index f92ffc736d..163e367018 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -605,6 +605,8 @@ export class Realm { private changeListeners = new RealmListeners(this, RealmEvent.Change); private beforeNotifyListeners = new RealmListeners(this, RealmEvent.BeforeNotify); private schemaListeners = new RealmListeners(this, RealmEvent.Schema); + /** @internal */ + public currentUpdateMode: UpdateMode | undefined; /** * Create a new {@link Realm} instance, at the default path. @@ -860,11 +862,20 @@ export class Realm { throw new Error("Cannot create an object from a detached RealmObject instance"); } if (!Object.values(UpdateMode).includes(mode)) { - throw new Error("Unsupported 'updateMode'. Only 'never', 'modified' or 'all' is supported."); + throw new Error( + `Unsupported 'updateMode'. Only '${UpdateMode.Never}', '${UpdateMode.Modified}' or '${UpdateMode.All}' is supported.`, + ); } this.internal.verifyOpen(); const helpers = this.classes.getHelpers(type); - const realmObject = RealmObject.create(this, values, mode, { helpers }); + + this.currentUpdateMode = mode; + let realmObject: RealmObject; + try { + realmObject = RealmObject.create(this, values, mode, { helpers }); + } finally { + this.currentUpdateMode = undefined; + } return isAsymmetric(helpers.objectSchema) ? undefined : realmObject; } @@ -954,7 +965,7 @@ export class Realm { throw new Error("You cannot query an asymmetric object."); } const table = binding.Helpers.getTable(this.internal, objectSchema.tableKey); - const value = properties.get(objectSchema.primaryKey).toBinding(primaryKey, undefined); + const value = properties.get(objectSchema.primaryKey).toBinding(primaryKey); try { const objKey = table.findPrimaryKey(value); if (binding.isEmptyObjKey(objKey)) { diff --git a/packages/realm/src/Set.ts b/packages/realm/src/Set.ts index 91c4772c3d..f3d931019f 100644 --- a/packages/realm/src/Set.ts +++ b/packages/realm/src/Set.ts @@ -78,7 +78,7 @@ export class RealmSet extends OrderedCollection { */ delete(value: T): boolean { assert.inTransaction(this.realm); - const [, success] = this.internal.removeAny(this.helpers.toBinding(value, undefined)); + const [, success] = this.internal.removeAny(this.helpers.toBinding(value)); return success; } @@ -92,7 +92,7 @@ export class RealmSet extends OrderedCollection { */ add(value: T): this { assert.inTransaction(this.realm); - this.internal.insertAny(this.helpers.toBinding(value, undefined)); + this.internal.insertAny(this.helpers.toBinding(value)); return this; } diff --git a/packages/realm/src/TypeHelpers.ts b/packages/realm/src/TypeHelpers.ts index 4c372ce2e2..4a92ea2c7a 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -22,7 +22,6 @@ import { Collection, GeoBox, GeoCircle, - GeoPoint, GeoPolygon, INTERNAL, List, @@ -73,7 +72,7 @@ export function toArrayBuffer(value: unknown, stringToBase64 = true) { /** @internal */ export type TypeHelpers = { - toBinding(value: T, createObj?: ObjCreator): binding.MixedArg; + toBinding(value: T, options?: { createObj?: ObjCreator; updateMode?: UpdateMode }): binding.MixedArg; fromBinding(value: unknown): T; }; @@ -250,7 +249,7 @@ const TYPES_MAPPING: Record Type const helpers = getClassHelpers(objectType); const { wrapObject } = helpers; return { - toBinding: nullPassthrough((value, createObj) => { + toBinding: nullPassthrough((value, options) => { if ( value instanceof RealmObject && value.constructor.name === objectType && @@ -260,10 +259,12 @@ const TYPES_MAPPING: Record Type } else { // TODO: Consider exposing a way for calling code to disable object creation assert.object(value, name); - // Some other object is assumed to be an unmanged object, that the user wants to create - const createdObject = RealmObject.create(realm, value, UpdateMode.Never, { + // Use the update mode if set; otherwise, the object is assumed to be an + // unmanaged object that the user wants to create. + // TODO: Ideally use `options?.updateMode` instead of `realm.currentUpdateMode`. + const createdObject = RealmObject.create(realm, value, realm.currentUpdateMode ?? UpdateMode.Never, { helpers, - createObj, + createObj: options?.createObj, }); return createdObject[INTERNAL]; }