From aa5b28712ea37a17f03f801766358634f3b39128 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:57:05 +0100 Subject: [PATCH 01/43] Implement support for flat collections in `Mixed` (#6364) * Differentiate use of 'mixedToBinding()' for query arg validation. * Refactor 'mixedFromBinding()' to own function and account for List and Dictionary. * Implement setting a flat list and dictionary in Mixed. * Implement accessing a flat list and dictionary in Mixed. * Add tests for storing and accessing flat lists and dictionaries in Mixed. * Refactor helper in test to not rely on collection position. * Add tests for Set in Mixed throwing. * Add tests for updating lists and dictionaries. * Add tests for removing items in lists and dictionaries. * Add tests for filtering lists and dictionaries by path. * Throw if adding a set via property accessors. * Group tests into separate sub-suites. * Guard for embedded objects being set as Mixed value. * Add tests for embedded objects in Mixed throwing. * Add more filtering tests. * Add 'at_keys' query tests to uncomment after Core bug fix. * Add tests for inserting into lists and dictionaries. * Add tests for notifications on lists. * Add tests for notifications on dictionaries. * Add tests for notifications on object when changed prop is list in mixed. * Add tests for invalidating old list and dictionary. * Minor updates to names. * Add tests for notifications on object when changed prop is dictionary in mixed. * Add tests for creating dictionary via object without prototype. * Add tests filtering by query path using IN. * Access array of changes only 1 time in notifications tests. * Remove unnecessary type assertion. * Update schema object names in tests. * Add link to Core bug. * Add tests for default list and dictionary in schema. * Add tests for setting lists and dictionaries outside transaction. * Add tests for spreading Realm and non-Realm objects as Dictionary. * Add unit tests for 'isPOJO()'. * Point to updated Core commit and enable related tests. * Wrap chai's 'instanceOf()' in custom helper to assert type. * Update helper function name to be consistent with other helpers. * Add internal docs for 'isQueryArg'. * Rename unit test file. * Refactor notification tests into 'observable.ts'. * Refactor notification tests to use test context. * Use named import of 'ObjectSchema'. * Group CRUD tests into subsuites. --- integration-tests/tests/src/tests/mixed.ts | 738 +++++++++++++++++- .../tests/src/tests/observable.ts | 318 +++++++- packages/realm/bindgen/js_opt_in_spec.yml | 2 + packages/realm/bindgen/js_spec.yml | 1 - packages/realm/src/OrderedCollection.ts | 13 +- packages/realm/src/PropertyHelpers.ts | 67 ++ packages/realm/src/TypeHelpers.ts | 109 ++- .../realm/src/tests/PropertyHelpers.test.ts | 72 ++ 8 files changed, 1250 insertions(+), 70 deletions(-) create mode 100644 packages/realm/src/tests/PropertyHelpers.test.ts diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 115108c826..34551a7fe8 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -16,9 +16,10 @@ // //////////////////////////////////////////////////////////////////////////// -import Realm, { BSON } from "realm"; +import Realm, { BSON, ObjectSchema } from "realm"; import { expect } from "chai"; -import { openRealmBefore } from "../hooks"; + +import { openRealmBefore, openRealmBeforeEach } from "../hooks"; interface ISingle { a: Realm.Mixed; @@ -49,7 +50,23 @@ interface IMixedSchema { value: Realm.Mixed; } -const SingleSchema: Realm.ObjectSchema = { +interface IMixedAndEmbedded { + mixedValue: Realm.Mixed; + embeddedObject: { value: Realm.Mixed }; +} + +interface IMixedWithDefaultCollections { + mixedWithDefaultList: Realm.Mixed; + mixedWithDefaultDictionary: Realm.Mixed; +} + +interface ICollectionsOfMixed { + list: Realm.List<Realm.Mixed>; + dictionary: Realm.Dictionary<Realm.Mixed>; + set: Realm.Set<Realm.Mixed>; +} + +const SingleSchema: ObjectSchema = { name: "mixed", properties: { a: "mixed", @@ -59,7 +76,7 @@ const SingleSchema: Realm.ObjectSchema = { }, }; -const VertexSchema: Realm.ObjectSchema = { +const VertexSchema: ObjectSchema = { name: "Vertex", properties: { a: "int", @@ -68,7 +85,7 @@ const VertexSchema: Realm.ObjectSchema = { }, }; -const MixNestedSchema: Realm.ObjectSchema = { +const MixNestedSchema: ObjectSchema = { name: "Nested", properties: { a: "mixed", @@ -77,7 +94,7 @@ const MixNestedSchema: Realm.ObjectSchema = { }, }; -const MixedNullableSchema: Realm.ObjectSchema = { +const MixedNullableSchema: ObjectSchema = { name: "mixed", properties: { nullable: "mixed", @@ -85,9 +102,73 @@ const MixedNullableSchema: Realm.ObjectSchema = { }, }; -const MixedSchema: Realm.ObjectSchema = { +const MixedSchema: ObjectSchema = { name: "MixedClass", - properties: { value: "mixed" }, + properties: { + value: "mixed", + }, +}; + +const MixedAndEmbeddedSchema: ObjectSchema = { + name: "MixedAndEmbedded", + properties: { + mixedValue: "mixed", + embeddedObject: "EmbeddedObject?", + }, +}; + +const EmbeddedObjectSchema: ObjectSchema = { + name: "EmbeddedObject", + embedded: true, + properties: { + value: "mixed", + }, +}; + +const CollectionsOfMixedSchema: ObjectSchema = { + name: "CollectionsOfMixed", + properties: { + list: "mixed[]", + dictionary: "mixed{}", + set: "mixed<>", + }, +}; + +const bool = true; +const int = 123; +const double = 123.456; +const d128 = BSON.Decimal128.fromString("6.022e23"); +const string = "hello"; +const date = new Date(); +const oid = new BSON.ObjectId(); +const uuid = new BSON.UUID(); +const nullValue = null; +const uint8Values = [0, 1, 2, 4, 8]; +const uint8Buffer = new Uint8Array(uint8Values).buffer; +const unmanagedRealmObject: IMixedSchema = { value: 1 }; + +// The `unmanagedRealmObject` is not added to these collections since a managed +// Realm object will be added by the individual tests after one has been created. +const flatListAllTypes: unknown[] = [bool, int, double, d128, string, date, oid, uuid, nullValue, uint8Buffer]; +const flatDictionaryAllTypes: Record<string, unknown> = { + bool, + int, + double, + d128, + string, + date, + oid, + uuid, + nullValue, + uint8Buffer, +}; + +const MixedWithDefaultCollectionsSchema: ObjectSchema = { + name: "MixedWithDefaultCollections", + properties: { + mixedWithDefaultList: { type: "mixed", default: [...flatListAllTypes] }, + mixedWithDefaultDictionary: { type: "mixed", default: { ...flatDictionaryAllTypes } }, + }, }; describe("Mixed", () => { @@ -136,6 +217,8 @@ describe("Mixed", () => { const oid = new BSON.ObjectId(); const uuid = new BSON.UUID(); const date = new Date(); + const list = [1, "two"]; + const dictionary = { number: 1, string: "two" }; const data = this.realm.write(() => this.realm.create<ISingle>(SingleSchema.name, { a: oid })); expect(typeof data.a === typeof oid, "should be the same type BSON.ObjectId"); @@ -145,27 +228,37 @@ describe("Mixed", () => { expect(typeof data.a === typeof uuid, "should be the same type BSON.UUID"); expect(String(data.a)).equals(uuid.toString(), "should have the same content BSON.UUID"); + this.realm.write(() => (data.a = date)); + expect(typeof data.a === typeof date, "should be the same type Date"); + expect(String(data.a)).equals(date.toString(), "should have the same content Date"); + this.realm.write(() => (data.a = d128)); expect(String(data.a)).equals(d128.toString(), "Should be the same BSON.Decimal128"); this.realm.write(() => (data.a = 12345678)); expect(data.a).equals(12345678, "Should be the same 12345678"); - this.realm.write(() => ((data.a = null), "Should be the same null")); + this.realm.write(() => (data.a = null)); expect(data.a).equals(null); - this.realm.write(() => ((data.a = undefined), "Should be the same null")); + this.realm.write(() => (data.a = undefined)); expect(data.a).equals(null); - }); - it("wrong type throws", function (this: RealmContext) { - expect(() => { - this.realm.write(() => this.realm.create(SingleSchema.name, { a: Object.create({}) })); - }).throws(Error, "Unable to convert an object with ctor 'Object' to a Mixed"); + + this.realm.write(() => (data.a = list)); + expect(data.a).to.be.instanceOf(Realm.List); + expect((data.a as Realm.List<any>)[0]).equals(1); + + this.realm.write(() => (data.a = dictionary)); + expect(data.a).to.be.instanceOf(Realm.Dictionary); + expect((data.a as Realm.Dictionary<any>).number).equals(1); }); }); describe("Nested types", () => { - openRealmBefore({ schema: [SingleSchema, VertexSchema, MixNestedSchema] }); + openRealmBefore({ + schema: [SingleSchema, VertexSchema, MixNestedSchema, MixedAndEmbeddedSchema, EmbeddedObjectSchema], + }); + it("support nested types", function (this: RealmContext) { const obj1 = this.realm.write(() => { const r = this.realm.create<IVertex>(VertexSchema.name, { a: 1, b: 0, c: 0 }); @@ -199,6 +292,27 @@ describe("Mixed", () => { expect((obj2.b as IVertex).a).equals(1, "Should be equal 1"); expect((obj2.b as IVertex).b).equals(0, "Should be equal 0"); }); + + it("throws if nested type is an embedded object", function (this: RealmContext) { + this.realm.write(() => { + // Create an object with an embedded object property. + const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { + mixedValue: null, + embeddedObject: { value: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Create an object with the Mixed property being the embedded object. + expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: embeddedObject })).to.throw( + "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", + ); + }); + const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); + // TODO: Length should equal 1 when this PR is merged: https://github.com/realm/realm-js/pull/6356 + // expect(objects.length).equals(1); + expect(objects.length).equals(2); + expect(objects[0].mixedValue).to.be.null; + }); }); describe("Nullable types", () => { @@ -220,23 +334,589 @@ describe("Mixed", () => { expect(value.nullable_list[4]).equals(5, "Should be equal 5"); }); }); - describe("Mixed arrays", () => { - openRealmBefore({ schema: [MixedSchema] }); - it("throws when creating an array of multiple values", function (this: RealmContext) { - const objectsBefore = this.realm.objects(MixedSchema.name); - expect(objectsBefore.length).equals(0); - - // check if the understandable error message is thrown - expect(() => { + + describe("Collection types", () => { + openRealmBeforeEach({ + schema: [ + MixedSchema, + MixedAndEmbeddedSchema, + MixedWithDefaultCollectionsSchema, + CollectionsOfMixedSchema, + EmbeddedObjectSchema, + ], + }); + + function expectRealmList(value: unknown): asserts value is Realm.List<any> { + expect(value).instanceOf(Realm.List); + } + + function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary<any> { + expect(value).instanceOf(Realm.Dictionary); + } + + function expectMatchingFlatList(list: unknown) { + expectRealmList(list); + expect(list.length).to.be.greaterThanOrEqual(flatListAllTypes.length); + + let index = 0; + for (const item of list) { + if (item instanceof Realm.Object) { + // @ts-expect-error Property `value` does exist. + expect(item.value).equals(unmanagedRealmObject.value); + } else if (item instanceof ArrayBuffer) { + expectMatchingUint8Buffer(item); + } else { + expect(String(item)).equals(String(flatListAllTypes[index])); + } + index++; + } + } + + function expectMatchingFlatDictionary(dictionary: unknown) { + expectRealmDictionary(dictionary); + expect(Object.keys(dictionary).length).to.be.greaterThanOrEqual(Object.keys(flatDictionaryAllTypes).length); + + for (const key in dictionary) { + const value = dictionary[key]; + if (key === "realmObject") { + expect(value).instanceOf(Realm.Object); + expect(value.value).equals(unmanagedRealmObject.value); + } else if (key === "uint8Buffer") { + expectMatchingUint8Buffer(value); + } else { + expect(String(value)).equals(String(flatDictionaryAllTypes[key])); + } + } + } + + function expectMatchingUint8Buffer(value: unknown) { + expect(value).instanceOf(ArrayBuffer); + expect([...new Uint8Array(value as ArrayBuffer)]).eql(uint8Values); + } + + describe("Flat collections", () => { + describe("CRUD operations", () => { + describe("Create and access", () => { + it("a list with different types (input: JS Array)", function (this: RealmContext) { + const { value: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: [...flatListAllTypes, realmObject], + }); + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expectMatchingFlatList(list); + }); + + it("a list with different types (input: Realm List)", function (this: RealmContext) { + const { value: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + // Create an object with a Realm List property type (i.e. not a Mixed type). + const realmObjectWithList = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: [...flatListAllTypes, realmObject], + }); + expectRealmList(realmObjectWithList.list); + // Use the Realm List as the value for the Mixed property on a different object. + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: realmObjectWithList.list }); + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expectMatchingFlatList(list); + }); + + it("a list with different types (input: Default value)", function (this: RealmContext) { + const { mixedWithDefaultList } = this.realm.write(() => { + // Pass an empty object in order to use the default value from the schema. + return this.realm.create<IMixedWithDefaultCollections>(MixedWithDefaultCollectionsSchema.name, {}); + }); + + expect(this.realm.objects(MixedWithDefaultCollectionsSchema.name).length).equals(1); + expectMatchingFlatList(mixedWithDefaultList); + }); + + it("a dictionary with different types (input: JS Object)", function (this: RealmContext) { + const { createdWithProto, createdWithoutProto } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const createdWithProto = this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { ...flatDictionaryAllTypes, realmObject }, + }); + const createdWithoutProto = this.realm.create<IMixedSchema>(MixedSchema.name, { + value: Object.assign(Object.create(null), { + ...flatDictionaryAllTypes, + realmObject, + }), + }); + return { createdWithProto, createdWithoutProto }; + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(3); + expectMatchingFlatDictionary(createdWithProto.value); + expectMatchingFlatDictionary(createdWithoutProto.value); + }); + + it("a dictionary with different types (input: Realm Dictionary)", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + // Create an object with a Realm Dictionary property type (i.e. not a Mixed type). + const realmObjectWithDictionary = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: { ...flatDictionaryAllTypes, realmObject }, + }); + expectRealmDictionary(realmObjectWithDictionary.dictionary); + // Use the Realm Dictionary as the value for the Mixed property on a different object. + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: realmObjectWithDictionary.dictionary }); + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expectMatchingFlatDictionary(dictionary); + }); + + it("a dictionary with different types (input: Default value)", function (this: RealmContext) { + const { mixedWithDefaultDictionary } = this.realm.write(() => { + // Pass an empty object in order to use the default value from the schema. + return this.realm.create<IMixedWithDefaultCollections>(MixedWithDefaultCollectionsSchema.name, {}); + }); + + expect(this.realm.objects(MixedWithDefaultCollectionsSchema.name).length).equals(1); + expectMatchingFlatDictionary(mixedWithDefaultDictionary); + }); + + it("a dictionary (input: Spread embedded Realm object)", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + const { embeddedObject } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { + embeddedObject: { value: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Spread the embedded object in order to use its entries as a dictionary in Mixed. + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { ...embeddedObject }, + }); + }); + + expectRealmDictionary(dictionary); + expect(dictionary).deep.equals({ value: 1 }); + }); + + it("a dictionary (input: Spread custom non-Realm object)", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + class CustomClass { + constructor(public value: number) {} + } + const customObject = new CustomClass(1); + + // Spread the embedded object in order to use its entries as a dictionary in Mixed. + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { ...customObject }, + }); + }); + + expectRealmDictionary(dictionary); + expect(dictionary).deep.equals({ value: 1 }); + }); + + it("inserts list items via `push()`", function (this: RealmContext) { + const { value: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: [] }); + }); + expectRealmList(list); + expect(list.length).equals(0); + + this.realm.write(() => { + list.push(...flatListAllTypes); + list.push(this.realm.create(MixedSchema.name, unmanagedRealmObject)); + }); + expectMatchingFlatList(list); + }); + + it("inserts dictionary entries", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: {} }); + }); + expectRealmDictionary(dictionary); + expect(Object.keys(dictionary).length).equals(0); + + this.realm.write(() => { + for (const key in flatDictionaryAllTypes) { + dictionary[key] = flatDictionaryAllTypes[key]; + } + dictionary.realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + }); + expectMatchingFlatDictionary(dictionary); + }); + }); + + describe("Update", () => { + it("updates list items via property setters", function (this: RealmContext) { + const { value: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: ["original", realmObject], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + expect(list[0]).equals("original"); + expect(list[1].value).equals("original"); + + this.realm.write(() => { + list[0] = "updated"; + list[1].value = "updated"; + }); + expect(list[0]).equals("updated"); + expect(list[1].value).equals("updated"); + + this.realm.write(() => { + list[0] = null; + list[1] = null; + }); + expect(list.length).equals(2); + expect(list[0]).to.be.null; + expect(list[1]).to.be.null; + }); + + it("updates dictionary entries via property setters", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { string: "original", realmObject }, + }); + }); + expectRealmDictionary(dictionary); + expect(Object.keys(dictionary).length).equals(2); + expect(dictionary.string).equals("original"); + expect(dictionary.realmObject.value).equals("original"); + + this.realm.write(() => { + dictionary.string = "updated"; + dictionary.realmObject.value = "updated"; + }); + expect(dictionary.string).equals("updated"); + expect(dictionary.realmObject.value).equals("updated"); + + this.realm.write(() => { + dictionary.string = null; + dictionary.realmObject = null; + }); + expect(Object.keys(dictionary).length).equals(2); + expect(dictionary.string).to.be.null; + expect(dictionary.realmObject).to.be.null; + }); + }); + + describe("Remove", () => { + it("removes list items via `remove()`", function (this: RealmContext) { + const { value: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: ["original", realmObject], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + + this.realm.write(() => { + list.remove(1); + }); + expect(list.length).equals(1); + expect(list[0]).equals("original"); + }); + + it("removes dictionary entries via `remove()`", function (this: RealmContext) { + const { value: dictionary } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { string: "original", realmObject }, + }); + }); + expectRealmDictionary(dictionary); + expect(Object.keys(dictionary).length).equals(2); + + this.realm.write(() => { + dictionary.remove("realmObject"); + }); + expect(Object.keys(dictionary).length).equals(1); + expect(dictionary.string).equals("original"); + expect(dictionary.realmObject).to.be.undefined; + }); + }); + }); + + describe("Filtering", () => { + it("filters by query path on list with different types", function (this: RealmContext) { + const expectedFilteredCount = 5; + const mixedList = [...flatListAllTypes]; + const nonExistentValue = "nonExistentValue"; + + this.realm.write(() => { + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { value: "not a list" }); + mixedList.push(this.realm.create(MixedSchema.name, { value: "not a list" })); + + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { value: mixedList }); + } + }); + const objects = this.realm.objects(MixedSchema.name); + expect(objects.length).equals(expectedFilteredCount + 2); + + let index = 0; + for (const itemToMatch of mixedList) { + // Objects with a list item that matches the `itemToMatch` at the GIVEN index. + let filtered = objects.filtered(`value[${index}] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value[${index}] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // Objects with a list item that matches the `itemToMatch` at ANY index. + filtered = objects.filtered(`value[*] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value[*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + index++; + } + }); + + it("filters by query path on dictionary with different types", function (this: RealmContext) { + const expectedFilteredCount = 5; + const mixedDictionary = { ...flatDictionaryAllTypes }; + const nonExistentValue = "nonExistentValue"; + const nonExistentKey = "nonExistentKey"; + + this.realm.write(() => { + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { value: "not a dictionary" }); + mixedDictionary.realmObject = this.realm.create(MixedSchema.name, { value: "not a dictionary" }); + + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { value: mixedDictionary }); + } + }); + const objects = this.realm.objects(MixedSchema.name); + expect(objects.length).equals(expectedFilteredCount + 2); + + const insertedValues = Object.values(mixedDictionary); + + for (const key in mixedDictionary) { + const valueToMatch = mixedDictionary[key]; + + // Objects with a dictionary value that matches the `valueToMatch` at the GIVEN key. + let filtered = objects.filtered(`value['${key}'] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value['${key}'] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`value['${nonExistentKey}'] == $0`, valueToMatch); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`value.${key} == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value.${key} == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`value.${nonExistentKey} == $0`, valueToMatch); + expect(filtered.length).equals(0); + + // Objects with a dictionary value that matches the `valueToMatch` at ANY key. + filtered = objects.filtered(`value[*] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value[*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // Objects with a dictionary containing a key that matches `key`. + filtered = objects.filtered(`value.@keys == $0`, key); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value.@keys == $0`, nonExistentKey); + expect(filtered.length).equals(0); + + // Objects with a dictionary with the key `key` matching any of the values inserted. + filtered = objects.filtered(`value.${key} IN $0`, insertedValues); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`value.${key} IN $0`, [nonExistentValue]); + expect(filtered.length).equals(0); + } + }); + }); + }); + + describe("Invalid operations", () => { + it("throws when creating a set (input: JS Set)", function (this: RealmContext) { + this.realm.write(() => { + expect(() => this.realm.create(MixedSchema.name, { value: new Set() })).to.throw( + "Using a Set as a Mixed value is not supported", + ); + }); + + const objects = this.realm.objects(MixedSchema.name); + // TODO: Length should equal 0 when this PR is merged: https://github.com/realm/realm-js/pull/6356 + // expect(objects.length).equals(0); + expect(objects.length).equals(1); + }); + + it("throws when creating a set (input: Realm Set)", function (this: RealmContext) { + this.realm.write(() => { + const { set } = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); + expect(set).instanceOf(Realm.Set); + expect(() => this.realm.create(MixedSchema.name, { value: set })).to.throw( + "Using a RealmSet as a Mixed value is not supported", + ); + }); + + const objects = this.realm.objects(MixedSchema.name); + // TODO: Length should equal 0 when this PR is merged: https://github.com/realm/realm-js/pull/6356 + // expect(objects.length).equals(0); + expect(objects.length).equals(1); + }); + + it("throws when updating a list item to a set", function (this: RealmContext) { + const { set, list } = this.realm.write(() => { + const realmObjectWithSet = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); + const realmObjectWithMixed = this.realm.create<IMixedSchema>(MixedSchema.name, { value: ["original"] }); + return { set: realmObjectWithSet.set, list: realmObjectWithMixed.value }; + }); + expectRealmList(list); + expect(list[0]).equals("original"); + + this.realm.write(() => { + expect(() => (list[0] = new Set())).to.throw("Using a Set as a Mixed value is not supported"); + expect(() => (list[0] = set)).to.throw("Using a RealmSet as a Mixed value is not supported"); + }); + expect(list[0]).equals("original"); + }); + + it("throws when updating a dictionary entry to a set", function (this: RealmContext) { + const { set, dictionary } = this.realm.write(() => { + const realmObjectWithSet = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); + const realmObjectWithMixed = this.realm.create<IMixedSchema>(MixedSchema.name, { + value: { string: "original" }, + }); + return { set: realmObjectWithSet.set, dictionary: realmObjectWithMixed.value }; + }); + expectRealmDictionary(dictionary); + expect(dictionary.string).equals("original"); + + this.realm.write(() => { + expect(() => (dictionary.string = new Set())).to.throw("Using a Set as a Mixed value is not supported"); + expect(() => (dictionary.string = set)).to.throw("Using a RealmSet as a Mixed value is not supported"); + }); + expect(dictionary.string).equals("original"); + }); + + it("throws when creating a list or dictionary with an embedded object", function (this: RealmContext) { this.realm.write(() => { - this.realm.create("MixedClass", { value: [123, false, "hello"] }); + // Create an object with an embedded object property. + const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { + embeddedObject: { value: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Create two objects with the Mixed property (`value`) being a list and + // dictionary (respectively) containing the reference to the embedded object. + expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: [embeddedObject] })).to.throw( + "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", + ); + expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: { embeddedObject } })).to.throw( + "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", + ); }); - }).throws(Error, "A mixed property cannot contain an array of values."); + const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); + // TODO: Length should equal 1 when this PR is merged: https://github.com/realm/realm-js/pull/6356 + // expect(objects.length).equals(1); + expect(objects.length).equals(3); + }); + + it("throws when setting a list or dictionary item to an embedded object", function (this: RealmContext) { + this.realm.write(() => { + // Create an object with an embedded object property. + const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { + embeddedObject: { value: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Create two objects with the Mixed property (`value`) + // being an empty list and dictionary (respectively). + const { mixedValue: list } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { + mixedValue: [], + }); + expectRealmList(list); + + const { mixedValue: dictionary } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { + mixedValue: {}, + }); + expectRealmDictionary(dictionary); + + expect(() => (list[0] = embeddedObject)).to.throw( + "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", + ); + expect( + () => (dictionary.prop = embeddedObject), + "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", + ); + }); + const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); + expect(objects.length).equals(3); + // Check that the list and dictionary are still empty. + expect((objects[1].mixedValue as Realm.List<any>).length).equals(0); + expect(Object.keys(objects[2].mixedValue as Realm.Dictionary<any>).length).equals(0); + }); + + it("throws when setting a list or dictionary outside a transaction", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: "original" }); + }); + expect(created.value).equals("original"); + expect(() => (created.value = ["a list item"])).to.throw( + "Cannot modify managed objects outside of a write transaction", + ); + expect(() => (created.value = { key: "a dictionary value" })).to.throw( + "Cannot modify managed objects outside of a write transaction", + ); + expect(created.value).equals("original"); + }); - // verify that the transaction has been rolled back - const objectsAfter = this.realm.objects(MixedSchema.name); - expect(objectsAfter.length).equals(0); + it("invalidates the list when removed", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: [1] }); + }); + const list = created.value; + expectRealmList(list); + + this.realm.write(() => { + created.value = null; + }); + expect(created.value).to.be.null; + expect(() => list[0]).to.throw("List is no longer valid"); + }); + + it("invalidates the dictionary when removed", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { value: { prop: 1 } }); + }); + const dictionary = created.value; + expectRealmDictionary(dictionary); + + this.realm.write(() => { + created.value = null; + }); + expect(created.value).to.be.null; + expect(() => dictionary.prop).to.throw("This collection is no more"); + }); }); + }); + + describe("Typed arrays in Mixed", () => { + openRealmBeforeEach({ schema: [MixedSchema] }); + it("supports datatypes with binary data contents", function (this: RealmContext) { const uint8Values1 = [0, 1, 2, 4, 8]; const uint8Values2 = [255, 128, 64, 32, 16, 8]; diff --git a/integration-tests/tests/src/tests/observable.ts b/integration-tests/tests/src/tests/observable.ts index 7c61efc765..54ca6e92bd 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -24,7 +24,7 @@ import { assert, expect } from "chai"; -import Realm, { CollectionChangeSet, DictionaryChangeSet, ObjectChangeSet, RealmEventName } from "realm"; +import Realm, { CollectionChangeSet, DictionaryChangeSet, ObjectChangeSet, ObjectSchema, RealmEventName } from "realm"; import { openRealmBeforeEach } from "../hooks"; import { createListenerStub } from "../utils/listener-stub"; @@ -186,8 +186,8 @@ async function expectCollectionNotifications( await expectNotifications( (listener) => collection.addListener(listener, keyPaths), (listener) => collection.removeListener(listener), - (expectedChange, c) => (_: Realm.Collection, actualChange: CollectionChangeSet) => { - expect(actualChange).deep.equals(expectedChange, `Changeset #${c} didn't match`); + (expectedChanges, c) => (_: Realm.Collection, actualChanges: CollectionChangeSet) => { + expect(actualChanges).deep.equals(expectedChanges, `Changeset #${c} didn't match`); }, changesAndActions, ); @@ -205,8 +205,11 @@ async function expectDictionaryNotifications( await expectNotifications( (listener) => dictionary.addListener(listener, keyPaths), (listener) => dictionary.removeListener(listener), - (expectedChange, c) => (_: Realm.Dictionary, actualChange: DictionaryChangeSet) => { - expect(actualChange).deep.equals(expectedChange, `Changeset #${c} didn't match`); + (expectedChanges, c) => (_: Realm.Dictionary, actualChanges: DictionaryChangeSet) => { + const errorMessage = `Changeset #${c} didn't match`; + expect(actualChanges.insertions).members(expectedChanges.insertions, errorMessage); + expect(actualChanges.modifications).members(expectedChanges.modifications, errorMessage); + expect(actualChanges.deletions).members(expectedChanges.deletions, errorMessage); }, changesAndActions, ); @@ -1373,4 +1376,309 @@ describe("Observable", () => { }); }); }); + + describe("Collections in Mixed", () => { + class ObjectWithMixed extends Realm.Object<ObjectWithMixed> { + mixedValue!: Realm.Types.Mixed; + + static schema: ObjectSchema = { + name: "ObjectWithMixed", + properties: { + mixedValue: "mixed", + }, + }; + } + + type CollectionsInMixedContext = { + objectWithList: Realm.Object<ObjectWithMixed> & ObjectWithMixed; + objectWithDictionary: Realm.Object<ObjectWithMixed> & ObjectWithMixed; + list: Realm.List<any>; + dictionary: Realm.Dictionary<any>; + } & RealmContext; + + openRealmBeforeEach({ schema: [ObjectWithMixed] }); + + beforeEach(function (this: CollectionsInMixedContext) { + this.objectWithList = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixedValue: [] }); + }); + this.list = this.objectWithList.mixedValue as Realm.List<any>; + expect(this.list).instanceOf(Realm.List); + + this.objectWithDictionary = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixedValue: {} }); + }); + this.dictionary = this.objectWithDictionary.mixedValue as Realm.Dictionary<any>; + expect(this.dictionary).instanceOf(Realm.Dictionary); + }); + + describe("Collection notifications", () => { + it("fires when inserting to top-level list", async function (this: CollectionsInMixedContext) { + await expectCollectionNotifications(this.list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.list.push("Amy"); + this.list.push("Mary"); + this.list.push("John"); + }); + }, + { + deletions: [], + insertions: [0, 1, 2], + newModifications: [], + oldModifications: [], + }, + ]); + }); + + it("fires when inserting to top-level dictionary", async function (this: CollectionsInMixedContext) { + await expectDictionaryNotifications(this.dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + () => { + this.realm.write(() => { + this.dictionary.amy = "Amy"; + this.dictionary.mary = "Mary"; + this.dictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: ["amy", "mary", "john"], + modifications: [], + }, + ]); + }); + + it("fires when updating top-level list", async function (this: CollectionsInMixedContext) { + await expectCollectionNotifications(this.list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.list.push("Amy"); + this.list.push("Mary"); + this.list.push("John"); + }); + }, + { + deletions: [], + insertions: [0, 1, 2], + newModifications: [], + oldModifications: [], + }, + () => { + this.realm.write(() => { + this.list[0] = "Updated Amy"; + this.list[2] = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0, 2], + oldModifications: [0, 2], + }, + ]); + }); + + it("fires when updating top-level dictionary", async function (this: CollectionsInMixedContext) { + await expectDictionaryNotifications(this.dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + () => { + this.realm.write(() => { + this.dictionary.amy = "Amy"; + this.dictionary.mary = "Mary"; + this.dictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: ["amy", "mary", "john"], + modifications: [], + }, + () => { + this.realm.write(() => { + this.dictionary.amy = "Updated Amy"; + this.dictionary.john = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + modifications: ["amy", "john"], + }, + ]); + }); + + it("fires when deleting from top-level list", async function (this: CollectionsInMixedContext) { + await expectCollectionNotifications(this.list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.list.push("Amy"); + this.list.push("Mary"); + this.list.push("John"); + }); + }, + { + deletions: [], + insertions: [0, 1, 2], + newModifications: [], + oldModifications: [], + }, + () => { + this.realm.write(() => { + this.list.remove(2); + }); + }, + { + deletions: [2], + insertions: [], + newModifications: [], + oldModifications: [], + }, + ]); + }); + + it("fires when deleting from top-level dictionary", async function (this: CollectionsInMixedContext) { + await expectDictionaryNotifications(this.dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + () => { + this.realm.write(() => { + this.dictionary.amy = "Amy"; + this.dictionary.mary = "Mary"; + this.dictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: ["amy", "mary", "john"], + modifications: [], + }, + () => { + this.realm.write(() => { + this.dictionary.remove("mary"); + }); + }, + { + deletions: ["mary"], + insertions: [], + modifications: [], + }, + ]); + }); + + it("does not fire when updating object in top-level list", async function (this: CollectionsInMixedContext) { + const realmObjectInList = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixedValue: "original" }); + }); + + await expectCollectionNotifications(this.list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + () => { + this.realm.write(() => { + this.list.push(realmObjectInList); + }); + expect(this.list.length).equals(1); + expect(realmObjectInList.mixedValue).equals("original"); + }, + { + deletions: [], + insertions: [0], + newModifications: [], + oldModifications: [], + }, + () => { + this.realm.write(() => { + realmObjectInList.mixedValue = "updated"; + }); + expect(realmObjectInList.mixedValue).equals("updated"); + }, + ]); + }); + + it("does not fire when updating object in top-level dictionary", async function (this: CollectionsInMixedContext) { + const realmObjectInDictionary = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixedValue: "original" }); + }); + + await expectDictionaryNotifications(this.dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + () => { + this.realm.write(() => { + this.dictionary.realmObject = realmObjectInDictionary; + }); + expect(realmObjectInDictionary.mixedValue).equals("original"); + }, + { + deletions: [], + insertions: ["realmObject"], + modifications: [], + }, + () => { + this.realm.write(() => { + realmObjectInDictionary.mixedValue = "updated"; + }); + expect(realmObjectInDictionary.mixedValue).equals("updated"); + }, + ]); + }); + }); + + describe("Object notifications", () => { + it("fires when inserting, updating, and deleting in top-level list", async function (this: CollectionsInMixedContext) { + await expectObjectNotifications(this.objectWithList, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Insert list item. + () => { + this.realm.write(() => { + this.list.push("Amy"); + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + // Update list item. + () => { + this.realm.write(() => { + this.list[0] = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + // Delete list item. + () => { + this.realm.write(() => { + this.list.remove(0); + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + ]); + }); + + it("fires when inserting, updating, and deleting in top-level dictionary", async function (this: CollectionsInMixedContext) { + await expectObjectNotifications(this.objectWithDictionary, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Insert dictionary item. + () => { + this.realm.write(() => { + this.dictionary.amy = "Amy"; + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + // Update dictionary item. + () => { + this.realm.write(() => { + this.dictionary.amy = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + // Delete dictionary item. + () => { + this.realm.write(() => { + this.dictionary.remove("amy"); + }); + }, + { deleted: false, changedProperties: ["mixedValue"] }, + ]); + }); + }); + }); }); diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index 52e80159fa..9ec1234bdb 100644 --- a/packages/realm/bindgen/js_opt_in_spec.yml +++ b/packages/realm/bindgen/js_opt_in_spec.yml @@ -262,6 +262,7 @@ classes: - feed_buffer - make_ssl_verify_callback - needs_file_format_upgrade + - get_mixed_type LogCategoryRef: methods: @@ -297,6 +298,7 @@ classes: - get_key - get_any - set_any + - set_collection - get_linked_object - get_backlink_count - get_backlink_view diff --git a/packages/realm/bindgen/js_spec.yml b/packages/realm/bindgen/js_spec.yml index 1fb7bbc882..b6e9f28542 100644 --- a/packages/realm/bindgen/js_spec.yml +++ b/packages/realm/bindgen/js_spec.yml @@ -25,4 +25,3 @@ classes: raw_dereference: sig: '() const -> Nullable<SharedSyncSession>' cppName: lock - diff --git a/packages/realm/src/OrderedCollection.ts b/packages/realm/src/OrderedCollection.ts index b4f69dac84..2c825fdce5 100644 --- a/packages/realm/src/OrderedCollection.ts +++ b/packages/realm/src/OrderedCollection.ts @@ -212,7 +212,7 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, /** @internal */ protected declare classHelpers: ClassHelpers | null; /** @internal */ - private declare mixedToBinding: (value: unknown) => binding.MixedArg; + private declare mixedToBinding: (value: unknown, options: { isQueryArg: boolean }) => binding.MixedArg; /** * Get an element of the ordered collection by index. @@ -797,14 +797,19 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, filtered(queryString: string, ...args: unknown[]): Results<T> { const { results: parent, realm, helpers } = this; const kpMapping = binding.Helpers.getKeypathMapping(realm.internal); - const bindingArgs = args.map((arg) => - Array.isArray(arg) ? arg.map((sub) => this.mixedToBinding(sub)) : this.mixedToBinding(arg), - ); + const bindingArgs = args.map((arg) => this.queryArgToBinding(arg)); const newQuery = parent.query.table.query(queryString, bindingArgs, kpMapping); const results = binding.Helpers.resultsAppendQuery(parent, newQuery); return new Results(realm, results, helpers); } + /** @internal */ + private queryArgToBinding(arg: unknown): binding.MixedArg | binding.MixedArg[] { + return Array.isArray(arg) + ? arg.map((innerArg) => this.mixedToBinding(innerArg, { isQueryArg: true })) + : this.mixedToBinding(arg, { isQueryArg: true }); + } + /** * Returns new _Results_ that represent a sorted view of this collection. * diff --git a/packages/realm/src/PropertyHelpers.ts b/packages/realm/src/PropertyHelpers.ts index 2074910af6..f60976c892 100644 --- a/packages/realm/src/PropertyHelpers.ts +++ b/packages/realm/src/PropertyHelpers.ts @@ -19,6 +19,7 @@ import { ClassHelpers, Dictionary, + List, OrderedCollectionHelpers, Realm, RealmSet, @@ -317,6 +318,61 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> }, }; }, + [binding.PropertyType.Mixed](options) { + const { + realm, + columnKey, + typeHelpers: { fromBinding, toBinding }, + } = options; + + return { + get: (obj) => { + try { + // We currently rely on the Core helper `get_mixed_type()` for calling `obj.get_any()` + // since doing it here in the SDK layer will cause the binding layer to throw for + // collections. It's non-trivial to do in the bindgen templates as a `binding.List` + // would have to be constructed using the `realm` and `obj`. Going via the helpers + // bypasses that as we will return a primitive (the data type). If possible, revisiting + // this for a more performant solution would be ideal as we now make an extra call into + // Core for each Mixed access, not only for collections. + const mixedType = binding.Helpers.getMixedType(obj, columnKey); + if (mixedType === binding.MixedDataType.List) { + return fromBinding(binding.List.make(realm.internal, obj, columnKey)); + } + if (mixedType === binding.MixedDataType.Dictionary) { + return fromBinding(binding.Dictionary.make(realm.internal, obj, columnKey)); + } + return defaultGet(options)(obj); + } catch (err) { + assert.isValid(obj); + throw err; + } + }, + set: (obj: binding.Obj, value: unknown) => { + assert.inTransaction(realm); + + if (value instanceof List || Array.isArray(value)) { + obj.setCollection(columnKey, binding.CollectionType.List); + const internal = binding.List.make(realm.internal, obj, columnKey); + let index = 0; + for (const item of value) { + internal.insertAny(index++, toBinding(item)); + } + } else if (value instanceof Dictionary || isPOJO(value)) { + obj.setCollection(columnKey, binding.CollectionType.Dictionary); + const internal = binding.Dictionary.make(realm.internal, obj, columnKey); + internal.removeAll(); + for (const key in value) { + internal.insertAny(key, toBinding(value[key])); + } + } else if (value instanceof RealmSet || value instanceof Set) { + throw new Error(`Using a ${value.constructor.name} as a Mixed value is not supported.`); + } else { + defaultSet(options)(obj, value); + } + }, + }; + }, }; function getPropertyHelpers(type: binding.PropertyType, options: PropertyOptions): PropertyHelpers { @@ -366,3 +422,14 @@ export function createPropertyHelpers(property: PropertyContext, options: Helper }); } } + +/** @internal */ +export function isPOJO(value: unknown): value is Record<string, unknown> { + return ( + typeof value === "object" && + value !== null && + // Lastly check for the absence of a prototype as POJOs + // can still be created using `Object.create(null)`. + (value.constructor === Object || !Object.getPrototypeOf(value)) + ); +} diff --git a/packages/realm/src/TypeHelpers.ts b/packages/realm/src/TypeHelpers.ts index be3da30a00..b1a02da03c 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -20,15 +20,18 @@ import { BSON, ClassHelpers, Collection, + Dictionary, GeoBox, GeoCircle, GeoPolygon, INTERNAL, List, ObjCreator, + OrderedCollectionHelpers, REALM, Realm, RealmObject, + RealmSet, TypeAssertionError, UpdateMode, assert, @@ -72,7 +75,10 @@ export function toArrayBuffer(value: unknown, stringToBase64 = true) { /** @internal */ export type TypeHelpers<T = unknown> = { - toBinding(value: T, options?: { createObj?: ObjCreator; updateMode?: UpdateMode }): binding.MixedArg; + toBinding( + value: T, + options?: { createObj?: ObjCreator; updateMode?: UpdateMode; isQueryArg?: boolean }, + ): binding.MixedArg; fromBinding(value: unknown): T; }; @@ -89,8 +95,21 @@ export type TypeOptions = { // TODO: Consider testing for expected object instance types and throw something similar to the legacy SDK: // "Only Realm instances are supported." (which should probably have been "RealmObject") // instead of relying on the binding to throw. -/** @internal */ -export function mixedToBinding(realm: binding.Realm, value: unknown): binding.MixedArg { +/** + * Convert an SDK value to a Binding value representation. + * @param realm The Realm used. + * @param value The value to convert. + * @param options Options needed. + * @param options.isQueryArg Whether the value to convert is a query argument used + * for `OrderedCollection.filtered()`. If so, this will be validated differently. + * @returns The `MixedArg` binding representation. + * @internal + */ +export function mixedToBinding( + realm: binding.Realm, + value: unknown, + { isQueryArg } = { isQueryArg: false }, +): binding.MixedArg { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { // Fast track pass through for the most commonly used types return value; @@ -99,21 +118,29 @@ export function mixedToBinding(realm: binding.Realm, value: unknown): binding.Mi } else if (value instanceof Date) { return binding.Timestamp.fromDate(value); } else if (value instanceof RealmObject) { + if (value.objectSchema().embedded) { + throw new Error(`Using an embedded object (${value.constructor.name}) as a Mixed value is not supported.`); + } const otherRealm = value[REALM].internal; assert.isSameRealm(realm, otherRealm, "Realm object is from another Realm"); return value[INTERNAL]; - } else if (value instanceof Collection) { - throw new Error(`Using a ${value.constructor.name} as Mixed value, is not yet supported`); - } else if (Array.isArray(value)) { - throw new TypeError("A mixed property cannot contain an array of values."); + } else if (value instanceof RealmSet || value instanceof Set) { + throw new Error(`Using a ${value.constructor.name} as a Mixed value is not supported.`); } else { - if (typeof value === "object" && value !== null) { - if (isGeoCircle(value)) { - return circleToBindingGeospatial(value); - } else if (isGeoBox(value)) { - return boxToBindingGeospatial(value); - } else if (isGeoPolygon(value)) { - return polygonToBindingGeospatial(value); + if (isQueryArg) { + if (value instanceof Collection || Array.isArray(value)) { + throw new Error(`Using a ${value.constructor.name} as a query argument is not supported.`); + } + // Geospatial types can currently only be used when querying and + // are not yet supported as standalone data types in the schema. + if (typeof value === "object") { + if (isGeoCircle(value)) { + return circleToBindingGeospatial(value); + } else if (isGeoBox(value)) { + return boxToBindingGeospatial(value); + } else if (isGeoPolygon(value)) { + return polygonToBindingGeospatial(value); + } } } // Convert typed arrays to an `ArrayBuffer` @@ -126,6 +153,40 @@ export function mixedToBinding(realm: binding.Realm, value: unknown): binding.Mi return value as binding.MixedArg; } } + +function mixedFromBinding(options: TypeOptions, value: binding.MixedArg): unknown { + const { realm, getClassHelpers } = options; + if (binding.Int64.isInt(value)) { + return binding.Int64.intToNum(value); + } else if (value instanceof binding.Timestamp) { + return value.toDate(); + } else if (value instanceof binding.Float) { + return value.value; + } else if (value instanceof binding.ObjLink) { + const table = binding.Helpers.getTable(realm.internal, value.tableKey); + const linkedObj = table.getObject(value.objKey); + const { wrapObject } = getClassHelpers(value.tableKey); + return wrapObject(linkedObj); + } else if (value instanceof binding.List) { + const collectionHelpers: OrderedCollectionHelpers = { + toBinding: mixedToBinding.bind(null, realm.internal), + fromBinding: mixedFromBinding.bind(null, options), + get(_: binding.Results, index: number) { + return value.getAny(index); + }, + }; + return new List(realm, value, collectionHelpers); + } else if (value instanceof binding.Dictionary) { + const typeHelpers: TypeHelpers<Realm.Mixed> = { + toBinding: mixedToBinding.bind(null, realm.internal), + fromBinding: mixedFromBinding.bind(null, options), + }; + return new Dictionary(realm, value, typeHelpers); + } else { + return value; + } +} + function isGeoCircle(value: object): value is GeoCircle { return "distance" in value && "center" in value && typeof value["distance"] === "number"; } @@ -294,25 +355,11 @@ const TYPES_MAPPING: Record<binding.PropertyType, (options: TypeOptions) => Type }, }; }, - [binding.PropertyType.Mixed]({ realm, getClassHelpers }) { + [binding.PropertyType.Mixed](options) { + const { realm } = options; return { toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding(value) { - if (binding.Int64.isInt(value)) { - return binding.Int64.intToNum(value); - } else if (value instanceof binding.Timestamp) { - return value.toDate(); - } else if (value instanceof binding.Float) { - return value.value; - } else if (value instanceof binding.ObjLink) { - const table = binding.Helpers.getTable(realm.internal, value.tableKey); - const linkedObj = table.getObject(value.objKey); - const { wrapObject } = getClassHelpers(value.tableKey); - return wrapObject(linkedObj); - } else { - return value; - } - }, + fromBinding: mixedFromBinding.bind(null, options), }; }, [binding.PropertyType.ObjectId]({ optional }) { diff --git a/packages/realm/src/tests/PropertyHelpers.test.ts b/packages/realm/src/tests/PropertyHelpers.test.ts new file mode 100644 index 0000000000..685a3784b7 --- /dev/null +++ b/packages/realm/src/tests/PropertyHelpers.test.ts @@ -0,0 +1,72 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { expect } from "chai"; + +import { isPOJO } from "../PropertyHelpers"; + +describe("PropertyHelpers", () => { + describe("isPOJO()", () => { + it("returns true for object literal", () => { + const object = {}; + expect(object.constructor).to.equal(Object); + expect(isPOJO(object)).to.be.true; + }); + + it("returns true for Object constructor", () => { + const object = new Object(); + expect(object.constructor).to.equal(Object); + expect(isPOJO(object)).to.be.true; + }); + + it("returns true for Object without prototype", () => { + let object = Object.assign(Object.create(null), {}); + expect(object.constructor).to.be.undefined; + expect(Object.getPrototypeOf(object)).to.be.null; + expect(isPOJO(object)).to.be.true; + + object = Object.create(null); + expect(object.constructor).to.be.undefined; + expect(Object.getPrototypeOf(object)).to.be.null; + expect(isPOJO(object)).to.be.true; + }); + + it("returns false for user-defined class", () => { + class CustomClass {} + const object = new CustomClass(); + expect(object.constructor).to.equal(CustomClass); + expect(isPOJO(object)).to.be.false; + }); + + it("returns false for user-defined class called Object", () => { + class Object {} + const object = new Object(); + expect(object.constructor).to.equal(Object); + expect(isPOJO(object)).to.be.false; + }); + + it("returns false for Arrays", () => { + expect(isPOJO([])).to.be.false; + expect(isPOJO(new Array(1))).to.be.false; + }); + + it("returns false for null", () => { + expect(isPOJO(null)).to.be.false; + }); + }); +}); From f27d77ed25481ffa92ddd47fc176008c1ac64128 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Tue, 2 Apr 2024 12:42:33 +0200 Subject: [PATCH 02/43] RJS-2680: Implement support for `Mixed` data type with nested collections (#6513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move geospatial helper functions to related file. * Implement setting nested lists in Mixed. * Implement setting nested dictionaries in Mixed. * Implement getting nested lists in Mixed. * Implement getting nested dictionaries in Mixed. * Test creating and accessing nested lists and dicts. * Make previous flat collections tests use the new 'expect' function. * Test that max nesting level throws. * Delegate throwing when using a Set to 'mixedToBinding()'. * Implement setting nested collections on a dictionary via setter. * Test nested collections on dictionary via setter. * Minor update to names of tests. * Combine nested and flat collections tests into same suite. * Implement setting nested collections on a list via setter. * Test nested collections on list via setter. * Refactor common test logic to helper functions. * Optimize property setter for hot-path and use friendlier err msg. * Refactor test helper function to build collections of any depth. * Implement inserting nested collections on a list via 'push()'. * Test nested collections on a list via 'push()'. * Test updating dictionary entry to nested collections via setter. * Test updating nested list/dictionary item via setter. * Test removing items from collections via 'remove()'. * Test object notifications when modifying nested collections. * Group previous notification tests into one test. * Group collection notifications tests into 'List' and 'Dictionary'. * Test collection notifications when modifying nested collections. * Remove collections from test context. * Test filtering by query path on nested collections. * Align object schema property names in tests. * Test filtering with int at_type. * Implement setting nested collections on a dictionary via 'set()' overloads. * Test JS Array method 'values()'. * Test JS Array method 'entries()'. * Implement getting nested collections on dictionary 'values()' and 'entries()'. * Test 'values()' and 'entries()' on dictionary with nested collections. * Remove unnecessary 'fromBinding()' calls. * Refactor collection helpers from 'PropertyHelpers' into the respective collection file. * Introduce list/dict sentinels to circumvent extra Core access. * Rename getter to default. * Remove redundant 'snapshotGet'. * Add abstract 'get' and 'set' to 'OrderedCollection'. * Rename the collection helpers to 'accessor'. * Move tests into subsuites. * Fix 'Results.update()'. * Support nested collections in 'pop()', 'shift()', 'unshift()', 'splice()'. * Test list 'pop()'. * Test list 'shift()'. * Test list 'unshift()'. * Test list 'splice()'. * Return 'not found' for collections searched for in 'indexOf()'. * Test ordered collection 'indexOf()'. * Support list/dict sentinels in JSI. * Test references per access. * Enable skipped tests after Core bug fix. * Point to updated Core. * Fix accessor for non-Mixed top-level collection with Mixed items. * Enable and fix previously skipped test. * Update 'mixed{}'. * Update 'mixed<>'. * Remove now-invalidated test. * Remove unused injectable from Node bindgen template. * Replace if-statements with switch. * Add explicit Results and Set accessors for Mixed. * Adapt to change in Core treating missing keys as null in queries. * Rename insertion function. * Include tests of Dictionary property type with Mixed. * Test reassigning to new collection and self-assignment. * Test mixed * Update 'mixed[]'. * Test results accessor. * Update error messages. * Make accessor helpers an object field rather than spread. * Suggestions for "nested collections in mixed" (#6566) * Fix type bundling issue * Inline functions into "create*Accessor*" functions * Refactored typeHelpers out of accessors * Remove leftover 'Symbol_for' in node-wrapper template. * Test not invalidating new collection. * Remove test for max nesting level. The max nesting level in debug in Core has been updated to be the same as for release. * Remove reliance on issue-fix in certain tests. * Add key path test for object listener on mixed field. * Use '.values()' and '.entries()' in iteration. * Update comments. * Add CHANGELOG entry. --------- Co-authored-by: Kræn Hansen <kraen.hansen@mongodb.com> --- CHANGELOG.md | 65 + .../tests/src/tests/dictionary.ts | 37 +- integration-tests/tests/src/tests/list.ts | 50 +- integration-tests/tests/src/tests/mixed.ts | 2709 ++++++++++++++--- .../tests/src/tests/observable.ts | 720 +++-- integration-tests/tests/src/tests/results.ts | 6 +- packages/realm/bindgen/js_opt_in_spec.yml | 20 +- packages/realm/bindgen/src/templates/jsi.ts | 22 +- packages/realm/bindgen/src/templates/node.ts | 10 + .../realm/bindgen/src/templates/typescript.ts | 4 +- packages/realm/src/Collection.ts | 53 +- packages/realm/src/Dictionary.ts | 243 +- packages/realm/src/GeoSpatial.ts | 18 + packages/realm/src/List.ts | 249 +- packages/realm/src/Object.ts | 27 +- packages/realm/src/OrderedCollection.ts | 172 +- packages/realm/src/PropertyHelpers.ts | 195 +- packages/realm/src/Realm.ts | 22 +- packages/realm/src/Results.ts | 102 +- packages/realm/src/Set.ts | 117 +- packages/realm/src/TypeHelpers.ts | 69 +- ...ers.test.ts => collection-helpers.test.ts} | 17 +- 22 files changed, 3900 insertions(+), 1027 deletions(-) rename packages/realm/src/tests/{PropertyHelpers.test.ts => collection-helpers.test.ts} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5820a32536..f92d885ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ +## vNext (TBD) + +### Enhancements +* A `mixed` value can now hold a `Realm.List` and `Realm.Dictionary` with nested collections. Note that `Realm.Set` is not supported as a `mixed` value. ([#6513](https://github.com/realm/realm-js/pull/6513)) + +```typescript +class CustomObject extends Realm.Object { + value!: Realm.Mixed; + + static schema: ObjectSchema = { + name: "CustomObject", + properties: { + value: "mixed", + }, + }; +} + +const realm = await Realm.open({ schema: [CustomObject] }); + +// Create an object with a dictionary value as the Mixed property, +// containing primitives and a list. +const realmObject = realm.write(() => { + return realm.create(CustomObject, { + value: { + num: 1, + string: "hello", + bool: true, + list: [ + { + dict: { + string: "world", + }, + }, + ], + }, + }); +}); + +// Accessing the collection value returns the managed collection. +// The default generic type argument is `unknown` (mixed). +const dictionary = realmObject.value as Realm.Dictionary; +const list = dictionary.list as Realm.List; +const leafDictionary = (list[0] as Realm.Dictionary).dict as Realm.Dictionary; +console.log(leafDictionary.string); // "world" + +// Update the Mixed property to a list. +realm.write(() => { + realmObject.value = [1, "hello", { newKey: "new value" }]; +}); +``` + +### Fixed +* <How to hit and notice issue? what was the impact?> ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) +* None + +### Compatibility +* React Native >= v0.71.4 +* Realm Studio v15.0.0. +* File format: generates Realms with format v24 (reads and upgrades file format v10 or later). + +### Internal +<!-- * Either mention core version or upgrade --> +<!-- * Using Realm Core vX.Y.Z --> +<!-- * Upgraded Realm Core from vX.Y.Z to vA.B.C --> + ## 12.7.1 (2024-04-19) ### Fixed diff --git a/integration-tests/tests/src/tests/dictionary.ts b/integration-tests/tests/src/tests/dictionary.ts index 145287f8e9..9f8915bd48 100644 --- a/integration-tests/tests/src/tests/dictionary.ts +++ b/integration-tests/tests/src/tests/dictionary.ts @@ -20,7 +20,6 @@ import { expect } from "chai"; import Realm, { PropertySchema } from "realm"; import { openRealmBefore, openRealmBeforeEach } from "../hooks"; -import { sleep } from "../utils/sleep"; type Item<ValueType = Realm.Mixed> = { dict: Realm.Dictionary<ValueType>; @@ -60,17 +59,6 @@ const DictTypedSchema: Realm.ObjectSchema = { }, }; -const DictMixedSchema = { - name: "MixedDictionary", - properties: { - dict1: "mixed{}", - dict2: "mixed{}", - }, -}; - -type IDictSchema = { - fields: Record<any, any>; -}; type ITwoDictSchema = { dict1: Record<any, any>; dict2: Record<any, any>; @@ -307,17 +295,17 @@ describe("Dictionary", () => { }); }); - // This is currently not supported - it.skip("can store dictionary values using string keys", function (this: RealmContext) { + it("can store dictionary values using string keys", function (this: RealmContext) { const item = this.realm.write(() => { const item = this.realm.create<Item>("Item", {}); const item2 = this.realm.create<Item>("Item", {}); - item2.dict.key1 = "Hello"; - item.dict.key1 = item2.dict; + item2.dict.key1 = "hello"; + item.dict.key1 = item2; return item; }); - // @ts-expect-error We expect a dictionary inside dictionary - expect(item.dict.key1.dict.key1).equals("hello"); + const innerObject = item.dict.key1 as Realm.Object<Item> & Item; + expect(innerObject).instanceOf(Realm.Object); + expect(innerObject.dict).deep.equals({ key1: "hello" }); }); it("can store a reference to itself using string keys", function (this: RealmContext) { @@ -599,7 +587,7 @@ describe("Dictionary", () => { }); describe("embedded models", () => { - openRealmBeforeEach({ schema: [DictTypedSchema, DictMixedSchema, EmbeddedChild] }); + openRealmBeforeEach({ schema: [DictTypedSchema, EmbeddedChild] }); it("inserts correctly", function (this: RealmContext) { this.realm.write(() => { this.realm.create(DictTypedSchema.name, { @@ -615,16 +603,5 @@ describe("Dictionary", () => { expect(dict_2.children1?.num).equal(4, "We expect children1#4"); expect(dict_2.children2?.num).equal(5, "We expect children2#5"); }); - - it("throws on invalid input", function (this: RealmContext) { - this.realm.write(() => { - expect(() => { - this.realm.create(DictMixedSchema.name, { - dict1: { children1: { num: 2 }, children2: { num: 3 } }, - dict2: { children1: { num: 4 }, children2: { num: 5 } }, - }); - }).throws("Unable to convert an object with ctor 'Object' to a Mixed"); - }); - }); }); }); diff --git a/integration-tests/tests/src/tests/list.ts b/integration-tests/tests/src/tests/list.ts index 40b408ed06..64e9b536b5 100644 --- a/integration-tests/tests/src/tests/list.ts +++ b/integration-tests/tests/src/tests/list.ts @@ -711,7 +711,7 @@ describe("Lists", () => { Error, "Requested index 2 calling set() on list 'LinkTypesObject.arrayCol' when max is 1", ); - expect(() => (array[-1] = { doubleCol: 1 })).throws(Error, "Index -1 cannot be less than zero."); + expect(() => (array[-1] = { doubleCol: 1 })).throws(Error, "Cannot set item at negative index -1"); //@ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. array["foo"] = "bar"; @@ -772,6 +772,7 @@ describe("Lists", () => { openRealmBeforeEach({ schema: [LinkTypeSchema, TestObjectSchema, PersonListSchema, PersonSchema, PrimitiveArraysSchema], }); + it("are typesafe", function (this: RealmContext) { let obj: ILinkTypeSchema; let prim: IPrimitiveArraysSchema; @@ -792,8 +793,10 @@ describe("Lists", () => { //@ts-expect-error TYPEBUG: type missmatch, forcecasting shouldn't be done obj.arrayCol = [this.realm.create<ITestObjectSchema>(TestObjectSchema.name, { doubleCol: 1.0 })]; expect(obj.arrayCol[0].doubleCol).equals(1.0); - obj.arrayCol = obj.arrayCol; // eslint-disable-line no-self-assign - expect(obj.arrayCol[0].doubleCol).equals(1.0); + + // TODO: Solve the "removeAll()" case for self-assignment. + // obj.arrayCol = obj.arrayCol; // eslint-disable-line no-self-assign + // expect(obj.arrayCol[0].doubleCol).equals(1.0); //@ts-expect-error Person is not assignable to boolean. expect(() => (prim.bool = [person])).throws( @@ -868,21 +871,6 @@ describe("Lists", () => { testAssign("data", DATA1); testAssign("date", DATE1); - function testAssignNull(name: string, expected: string) { - //@ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. - expect(() => (prim[name] = [null])).throws(Error, `Expected '${name}[0]' to be ${expected}, got null`); - //@ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. - expect(prim[name].length).equals(1); - } - - testAssignNull("bool", "a boolean"); - testAssignNull("int", "a number or bigint"); - testAssignNull("float", "a number"); - testAssignNull("double", "a number"); - testAssignNull("string", "a string"); - testAssignNull("data", "an instance of ArrayBuffer"); - testAssignNull("date", "an instance of Date"); - testAssign("optBool", true); testAssign("optInt", 1); testAssign("optFloat", 1.1); @@ -905,7 +893,33 @@ describe("Lists", () => { //@ts-expect-error throws on modification outside of transaction. expect(() => (prim.bool = [])).throws("Cannot modify managed objects outside of a write transaction."); }); + + it("throws when assigning null to non-nullable", function (this: RealmContext) { + const realm = this.realm; + const prim = realm.write(() => realm.create<IPrimitiveArraysSchema>(PrimitiveArraysSchema.name, {})); + + function testAssignNull(name: string, expected: string) { + expect(() => { + realm.write(() => { + // @ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. + prim[name] = [null]; + }); + }).throws(Error, `Expected '${name}[0]' to be ${expected}, got null`); + + // @ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. + expect(prim[name].length).equals(0); + } + + testAssignNull("bool", "a boolean"); + testAssignNull("int", "a number or bigint"); + testAssignNull("float", "a number"); + testAssignNull("double", "a number"); + testAssignNull("string", "a string"); + testAssignNull("data", "an instance of ArrayBuffer"); + testAssignNull("date", "an instance of Date"); + }); }); + describe("operations", () => { openRealmBeforeEach({ schema: [LinkTypeSchema, TestObjectSchema, PersonSchema, PersonListSchema] }); it("supports enumeration", function (this: RealmContext) { diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 34551a7fe8..1441ff024e 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -47,12 +47,12 @@ interface IMixedNullable { } interface IMixedSchema { - value: Realm.Mixed; + mixed: Realm.Mixed; } interface IMixedAndEmbedded { - mixedValue: Realm.Mixed; - embeddedObject: { value: Realm.Mixed }; + mixed: Realm.Mixed; + embeddedObject: { mixed: Realm.Mixed }; } interface IMixedWithDefaultCollections { @@ -105,14 +105,14 @@ const MixedNullableSchema: ObjectSchema = { const MixedSchema: ObjectSchema = { name: "MixedClass", properties: { - value: "mixed", + mixed: "mixed", }, }; const MixedAndEmbeddedSchema: ObjectSchema = { name: "MixedAndEmbedded", properties: { - mixedValue: "mixed", + mixed: "mixed", embeddedObject: "EmbeddedObject?", }, }; @@ -121,7 +121,7 @@ const EmbeddedObjectSchema: ObjectSchema = { name: "EmbeddedObject", embedded: true, properties: { - value: "mixed", + mixed: "mixed", }, }; @@ -135,7 +135,7 @@ const CollectionsOfMixedSchema: ObjectSchema = { }; const bool = true; -const int = 123; +const int = BigInt(123); const double = 123.456; const d128 = BSON.Decimal128.fromString("6.022e23"); const string = "hello"; @@ -145,12 +145,32 @@ const uuid = new BSON.UUID(); const nullValue = null; const uint8Values = [0, 1, 2, 4, 8]; const uint8Buffer = new Uint8Array(uint8Values).buffer; -const unmanagedRealmObject: IMixedSchema = { value: 1 }; - -// The `unmanagedRealmObject` is not added to these collections since a managed +// The `unmanagedRealmObject` is not added to the collections below since a managed // Realm object will be added by the individual tests after one has been created. -const flatListAllTypes: unknown[] = [bool, int, double, d128, string, date, oid, uuid, nullValue, uint8Buffer]; -const flatDictionaryAllTypes: Record<string, unknown> = { +const unmanagedRealmObject: IMixedSchema = { mixed: 1 }; + +/** + * An array of values representing each Realm data type allowed as `Mixed`, + * except for a managed Realm Object, a nested list, and a nested dictionary. + */ +const primitiveTypesList: readonly unknown[] = [ + bool, + int, + double, + d128, + string, + date, + oid, + uuid, + nullValue, + uint8Buffer, +]; + +/** + * An object with values representing each Realm data type allowed as `Mixed`, + * except for a managed Realm Object, a nested list, and a nested dictionary. + */ +const primitiveTypesDictionary: Readonly<Record<string, unknown>> = { bool, int, double, @@ -166,8 +186,8 @@ const flatDictionaryAllTypes: Record<string, unknown> = { const MixedWithDefaultCollectionsSchema: ObjectSchema = { name: "MixedWithDefaultCollections", properties: { - mixedWithDefaultList: { type: "mixed", default: [...flatListAllTypes] }, - mixedWithDefaultDictionary: { type: "mixed", default: { ...flatDictionaryAllTypes } }, + mixedWithDefaultList: { type: "mixed", default: [...primitiveTypesList] }, + mixedWithDefaultDictionary: { type: "mixed", default: { ...primitiveTypesDictionary } }, }, }; @@ -294,24 +314,25 @@ describe("Mixed", () => { }); it("throws if nested type is an embedded object", function (this: RealmContext) { - this.realm.write(() => { - // Create an object with an embedded object property. - const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { - mixedValue: null, - embeddedObject: { value: 1 }, + // Create an object with an embedded object property. + const { embeddedObject } = this.realm.write(() => { + return this.realm.create(MixedAndEmbeddedSchema.name, { + mixed: null, + embeddedObject: { mixed: 1 }, }); - expect(embeddedObject).instanceOf(Realm.Object); - - // Create an object with the Mixed property being the embedded object. - expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: embeddedObject })).to.throw( - "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", - ); }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Create an object with the Mixed property being the embedded object. + expect(() => { + this.realm.write(() => { + this.realm.create(MixedAndEmbeddedSchema.name, { mixed: embeddedObject }); + }); + }).to.throw("Using an embedded object (EmbeddedObject) as a Mixed value is not supported"); + const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); - // TODO: Length should equal 1 when this PR is merged: https://github.com/realm/realm-js/pull/6356 - // expect(objects.length).equals(1); - expect(objects.length).equals(2); - expect(objects[0].mixedValue).to.be.null; + expect(objects.length).equals(1); + expect(objects[0].mixed).to.be.null; }); }); @@ -346,443 +367,2326 @@ describe("Mixed", () => { ], }); - function expectRealmList(value: unknown): asserts value is Realm.List<any> { + function expectRealmList(value: unknown): asserts value is Realm.List<unknown> { expect(value).instanceOf(Realm.List); } - function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary<any> { + function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary<unknown> { expect(value).instanceOf(Realm.Dictionary); } - function expectMatchingFlatList(list: unknown) { - expectRealmList(list); - expect(list.length).to.be.greaterThanOrEqual(flatListAllTypes.length); + function expectRealmResults(value: unknown): asserts value is Realm.Results<unknown> { + expect(value).instanceOf(Realm.Results); + } + + /** + * Expects the provided value to contain: + * - All values in {@link primitiveTypesList}. + * - Optionally the managed object of {@link unmanagedRealmObject}. + * - If the provided value is not a leaf list, additionally: + * - A nested list with the same criteria. + * - A nested dictionary with the same criteria. + */ + function expectOrderedCollectionOfAllTypes(collection: Realm.List | Realm.Results) { + expect(collection.length).greaterThanOrEqual(primitiveTypesList.length); let index = 0; - for (const item of list) { + for (const item of collection) { if (item instanceof Realm.Object) { - // @ts-expect-error Property `value` does exist. - expect(item.value).equals(unmanagedRealmObject.value); + // @ts-expect-error Expecting `mixed` to exist. + expect(item.mixed).equals(unmanagedRealmObject.mixed); } else if (item instanceof ArrayBuffer) { - expectMatchingUint8Buffer(item); + expectUint8Buffer(item); + } else if (item instanceof Realm.List) { + expectListOfAllTypes(item); + } else if (item instanceof Realm.Dictionary) { + expectDictionaryOfAllTypes(item); } else { - expect(String(item)).equals(String(flatListAllTypes[index])); + expect(String(item)).equals(String(primitiveTypesList[index])); } index++; } } - function expectMatchingFlatDictionary(dictionary: unknown) { + /** + * Expects the provided value to be a {@link Realm.List} containing + * items with the same criteria as {@link expectOrderedCollectionOfAllTypes}. + */ + function expectListOfAllTypes(list: unknown): asserts list is Realm.List<unknown> { + expectRealmList(list); + expectOrderedCollectionOfAllTypes(list); + } + + /** + * Expects the provided value to be a {@link Realm.Results} containing + * items with the same criteria as {@link expectOrderedCollectionOfAllTypes}. + */ + function expectResultsOfAllTypes(results: unknown): asserts results is Realm.Results<unknown> { + expectRealmResults(results); + expectOrderedCollectionOfAllTypes(results); + } + + /** + * Expects the provided value to be a {@link Realm.Dictionary} containing: + * - All entries in {@link primitiveTypesDictionary}. + * - Optional key `realmObject`: The managed object of {@link unmanagedRealmObject}. + * - If the provided value is not a leaf dictionary, additionally: + * - Key `list`: A nested list with the same criteria. + * - Key `dictionary`: A nested dictionary with the same criteria. + */ + function expectDictionaryOfAllTypes(dictionary: unknown): asserts dictionary is Realm.Dictionary<unknown> { expectRealmDictionary(dictionary); - expect(Object.keys(dictionary).length).to.be.greaterThanOrEqual(Object.keys(flatDictionaryAllTypes).length); + expect(Object.keys(dictionary)).to.include.members(Object.keys(primitiveTypesDictionary)); for (const key in dictionary) { const value = dictionary[key]; if (key === "realmObject") { expect(value).instanceOf(Realm.Object); - expect(value.value).equals(unmanagedRealmObject.value); + // @ts-expect-error Expecting `mixed` to exist. + expect(value.mixed).equals(unmanagedRealmObject.mixed); } else if (key === "uint8Buffer") { - expectMatchingUint8Buffer(value); + expectUint8Buffer(value); + } else if (key === "list") { + expectListOfAllTypes(value); + } else if (key === "dictionary") { + expectDictionaryOfAllTypes(value); } else { - expect(String(value)).equals(String(flatDictionaryAllTypes[key])); + expect(String(value)).equals(String(primitiveTypesDictionary[key])); } } } - function expectMatchingUint8Buffer(value: unknown) { + /** + * Expects the provided value to be a {@link Realm.List} containing: + * - A `Realm.List` of: + * - A `Realm.List` of: + * - All values in {@link primitiveTypesList}. + * - The managed object of {@link unmanagedRealmObject}. + */ + function expectListOfListsOfAllTypes(list: unknown): asserts list is Realm.List<unknown> { + expectRealmList(list); + expect(list.length).equals(1); + const [depth1] = list; + expectRealmList(depth1); + expect(depth1.length).equals(1); + const [depth2] = depth1; + expectListOfAllTypes(depth2); + } + + /** + * Expects the provided value to be a {@link Realm.List} containing: + * - A `Realm.Dictionary` of: + * - Key `depth2`: A `Realm.Dictionary` of: + * - All entries in {@link primitiveTypesDictionary}. + * - Key `realmObject`: The managed object of {@link unmanagedRealmObject}. + */ + function expectListOfDictionariesOfAllTypes(list: unknown): asserts list is Realm.List<unknown> { + expectRealmList(list); + expect(list.length).equals(1); + const [depth1] = list; + expectRealmDictionary(depth1); + expectKeys(depth1, ["depth2"]); + const { depth2 } = depth1; + expectDictionaryOfAllTypes(depth2); + } + + /** + * Expects the provided value to be a {@link Realm.Dictionary} containing: + * - Key `depth1`: A `Realm.List` of: + * - A `Realm.List` of: + * - All values in {@link primitiveTypesList}. + * - The managed object of {@link unmanagedRealmObject}. + */ + function expectDictionaryOfListsOfAllTypes(dictionary: unknown): asserts dictionary is Realm.Dictionary<unknown> { + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["depth1"]); + const { depth1 } = dictionary; + expectRealmList(depth1); + expect(depth1.length).equals(1); + const [depth2] = depth1; + expectListOfAllTypes(depth2); + } + + /** + * Expects the provided value to be a {@link Realm.Dictionary} containing: + * - Key `depth1`: A `Realm.Dictionary` of: + * - Key `depth2`: A `Realm.Dictionary` of: + * - All entries in {@link primitiveTypesDictionary}. + * - Key `realmObject`: The managed object of {@link unmanagedRealmObject}. + */ + function expectDictionaryOfDictionariesOfAllTypes( + dictionary: unknown, + ): asserts dictionary is Realm.Dictionary<unknown> { + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["depth1"]); + const { depth1 } = dictionary; + expectRealmDictionary(depth1); + expectKeys(depth1, ["depth2"]); + const { depth2 } = depth1; + expectDictionaryOfAllTypes(depth2); + } + + function expectUint8Buffer(value: unknown): asserts value is ArrayBuffer { expect(value).instanceOf(ArrayBuffer); expect([...new Uint8Array(value as ArrayBuffer)]).eql(uint8Values); } - describe("Flat collections", () => { - describe("CRUD operations", () => { - describe("Create and access", () => { - it("a list with different types (input: JS Array)", function (this: RealmContext) { - const { value: list } = this.realm.write(() => { + /** + * Builds an unmanaged list containing: + * - All values in {@link primitiveTypesList}. + * - For each depth except the last, additionally: + * - A nested list with the same criteria. + * - A nested dictionary with the same criteria. + */ + function buildListOfCollectionsOfAllTypes({ depth, list = [] }: { depth: number; list?: unknown[] }) { + expect(depth).greaterThan(0); + expect(list.length).equals(0); + + list.push(...primitiveTypesList); + if (depth > 1) { + list.push(buildListOfCollectionsOfAllTypes({ depth: depth - 1 })); + list.push(buildDictionaryOfCollectionsOfAllTypes({ depth: depth - 1 })); + } + + return list; + } + + /** + * Builds an unmanaged dictionary containing: + * - All entries in {@link primitiveTypesDictionary}. + * - For each depth except the last, additionally: + * - Key `list`: A nested list with the same criteria. + * - Key `dictionary`: A nested dictionary with the same criteria. + */ + function buildDictionaryOfCollectionsOfAllTypes({ + depth, + dictionary = {}, + }: { + depth: number; + dictionary?: Record<string, unknown>; + }) { + expect(depth).greaterThan(0); + expect(Object.keys(dictionary).length).equals(0); + + Object.assign(dictionary, primitiveTypesDictionary); + if (depth > 1) { + dictionary.list = buildListOfCollectionsOfAllTypes({ depth: depth - 1 }); + dictionary.dictionary = buildDictionaryOfCollectionsOfAllTypes({ depth: depth - 1 }); + } + + return dictionary; + } + + function expectKeys(dictionary: Realm.Dictionary, keys: string[]) { + expect(Object.keys(dictionary)).members(keys); + } + + describe("CRUD operations", () => { + describe("Create and access", () => { + describe("List", () => { + it("has all primitive types (input: JS Array)", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: [...flatListAllTypes, realmObject], - }); + const unmanagedList = [...primitiveTypesList, realmObject]; + + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedList, + }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, + }).list; + + return { list1, list2 }; }); expect(this.realm.objects(MixedSchema.name).length).equals(2); - expectMatchingFlatList(list); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); }); - it("a list with different types (input: Realm List)", function (this: RealmContext) { - const { value: list } = this.realm.write(() => { + it("has all primitive types (input: Realm List)", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [...primitiveTypesList, realmObject]; + // Create an object with a Realm List property type (i.e. not a Mixed type). - const realmObjectWithList = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { - list: [...flatListAllTypes, realmObject], - }); - expectRealmList(realmObjectWithList.list); + const listToInsert = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, + }).list; + expectRealmList(listToInsert); + // Use the Realm List as the value for the Mixed property on a different object. - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: realmObjectWithList.list }); + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: listToInsert }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: listToInsert, + }).list; + + return { list1, list2 }; }); expect(this.realm.objects(MixedSchema.name).length).equals(2); - expectMatchingFlatList(list); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(2); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); }); - it("a list with different types (input: Default value)", function (this: RealmContext) { + it("has all primitive types (input: Default value)", function (this: RealmContext) { const { mixedWithDefaultList } = this.realm.write(() => { // Pass an empty object in order to use the default value from the schema. return this.realm.create<IMixedWithDefaultCollections>(MixedWithDefaultCollectionsSchema.name, {}); }); expect(this.realm.objects(MixedWithDefaultCollectionsSchema.name).length).equals(1); - expectMatchingFlatList(mixedWithDefaultList); + expectListOfAllTypes(mixedWithDefaultList); + }); + + it("has nested lists of all primitive types", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [[[...primitiveTypesList, realmObject]]]; + + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, + }).list; + + return { list1, list2 }; + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectListOfListsOfAllTypes(list1); + expectListOfListsOfAllTypes(list2); + }); + + it("has nested dictionaries of all primitive types", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [{ depth2: { ...primitiveTypesDictionary, realmObject } }]; + + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, + }).list; + + return { list1, list2 }; + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectListOfDictionariesOfAllTypes(list1); + expectListOfDictionariesOfAllTypes(list2); + }); + + it("has mix of nested collections of all types", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const unmanagedList = buildListOfCollectionsOfAllTypes({ depth: 4 }); + + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, + }).list; + + return { list1, list2 }; + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(1); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); + }); + + it("inserts all primitive types via `push()`", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { list: [] }).list; + + return { list1, list2 }; + }); + expectRealmList(list1); + expectRealmList(list2); + expect(list1.length).equals(0); + expect(list2.length).equals(0); + + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + list1.push(...primitiveTypesList, realmObject); + list2.push(...primitiveTypesList, realmObject); + }); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); + }); + + it("inserts nested lists of all primitive types via `push()`", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { list: [] }).list; + + return { list1, list2 }; + }); + expectRealmList(list1); + expectRealmList(list2); + expect(list1.length).equals(0); + expect(list2.length).equals(0); + + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [[...primitiveTypesList, realmObject]]; + + list1.push(unmanagedList); + list2.push(unmanagedList); + }); + expectListOfListsOfAllTypes(list1); + expectListOfListsOfAllTypes(list2); }); - it("a dictionary with different types (input: JS Object)", function (this: RealmContext) { - const { createdWithProto, createdWithoutProto } = this.realm.write(() => { + it("inserts nested dictionaries of all primitive types via `push()`", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { list: [] }).list; + + return { list1, list2 }; + }); + expectRealmList(list1); + expectRealmList(list2); + expect(list1.length).equals(0); + expect(list2.length).equals(0); + + this.realm.write(() => { const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); - const createdWithProto = this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { ...flatDictionaryAllTypes, realmObject }, + const unmanagedDictionary = { depth2: { ...primitiveTypesDictionary, realmObject } }; + + list1.push(unmanagedDictionary); + list2.push(unmanagedDictionary); + }); + expectListOfDictionariesOfAllTypes(list1); + expectListOfDictionariesOfAllTypes(list2); + }); + + it("inserts mix of nested collections of all types via `push()`", function (this: RealmContext) { + const { list1, list2 } = this.realm.write(() => { + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { list: [] }).list; + + return { list1, list2 }; + }); + expectRealmList(list1); + expectRealmList(list2); + expect(list1.length).equals(0); + expect(list2.length).equals(0); + + const unmanagedList = buildListOfCollectionsOfAllTypes({ depth: 4 }); + this.realm.write(() => { + for (const item of unmanagedList) { + list1.push(item); + list2.push(item); + } + }); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); + }); + + it("returns different reference for each access", function (this: RealmContext) { + const unmanagedList: unknown[] = []; + const { created1, created2 } = this.realm.write(() => { + const created1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }); + const created2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: unmanagedList, }); - const createdWithoutProto = this.realm.create<IMixedSchema>(MixedSchema.name, { - value: Object.assign(Object.create(null), { - ...flatDictionaryAllTypes, - realmObject, - }), + + return { created1, created2 }; + }); + expectRealmList(created1.mixed); + expectRealmList(created2.list); + + // @ts-expect-error Testing different types. + expect(created1.mixed === unmanagedList).to.be.false; + expect(created1.mixed === created1.mixed).to.be.false; + expect(Object.is(created1.mixed, created1.mixed)).to.be.false; + + // @ts-expect-error Testing different types. + expect(created2.list === unmanagedList).to.be.false; + expect(created2.list === created2.list).to.be.false; + expect(Object.is(created2.list, created2.list)).to.be.false; + + const { list1, list2 } = this.realm.write(() => { + const list1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [unmanagedList] }).mixed; + const list2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + list: [unmanagedList], + }).list; + + return { list1, list2 }; + }); + expectRealmList(list1); + expectRealmList(list2); + + expect(list1[0] === unmanagedList).to.be.false; + expect(list1[0] === list1[0]).to.be.false; + expect(Object.is(list1[0], list1[0])).to.be.false; + + expect(list2[0] === unmanagedList).to.be.false; + expect(list2[0] === list2[0]).to.be.false; + expect(Object.is(list2[0], list2[0])).to.be.false; + }); + }); + + describe("Dictionary", () => { + it("has all primitive types (input: JS Object)", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { ...primitiveTypesDictionary, realmObject }; + + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }).dictionary; + + return { dictionary1, dictionary2 }; + }); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + it("has all primitive types (input: JS Object w/o proto)", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = Object.assign(Object.create(null), { + ...primitiveTypesDictionary, + realmObject, }); - return { createdWithProto, createdWithoutProto }; + + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expect(this.realm.objects(MixedSchema.name).length).equals(3); - expectMatchingFlatDictionary(createdWithProto.value); - expectMatchingFlatDictionary(createdWithoutProto.value); + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); }); - it("a dictionary with different types (input: Realm Dictionary)", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { + it("has all primitive types (input: Realm Dictionary)", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + // Create an object with a Realm Dictionary property type (i.e. not a Mixed type). - const realmObjectWithDictionary = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { - dictionary: { ...flatDictionaryAllTypes, realmObject }, - }); - expectRealmDictionary(realmObjectWithDictionary.dictionary); + const dictionaryToInsert = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: { ...primitiveTypesDictionary, realmObject }, + }).dictionary; + expectRealmDictionary(dictionaryToInsert); + // Use the Realm Dictionary as the value for the Mixed property on a different object. - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: realmObjectWithDictionary.dictionary }); + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: dictionaryToInsert, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: dictionaryToInsert, + }).dictionary; + + return { dictionary1, dictionary2 }; }); expect(this.realm.objects(MixedSchema.name).length).equals(2); - expectMatchingFlatDictionary(dictionary); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(2); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); }); - it("a dictionary with different types (input: Default value)", function (this: RealmContext) { + it("has all primitive types (input: Default value)", function (this: RealmContext) { const { mixedWithDefaultDictionary } = this.realm.write(() => { // Pass an empty object in order to use the default value from the schema. return this.realm.create<IMixedWithDefaultCollections>(MixedWithDefaultCollectionsSchema.name, {}); }); expect(this.realm.objects(MixedWithDefaultCollectionsSchema.name).length).equals(1); - expectMatchingFlatDictionary(mixedWithDefaultDictionary); + expectDictionaryOfAllTypes(mixedWithDefaultDictionary); }); - it("a dictionary (input: Spread embedded Realm object)", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { + it("can use the spread of embedded Realm object", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { const { embeddedObject } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { - embeddedObject: { value: 1 }, + embeddedObject: { mixed: 1 }, }); expect(embeddedObject).instanceOf(Realm.Object); // Spread the embedded object in order to use its entries as a dictionary in Mixed. - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { ...embeddedObject }, - }); + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { ...embeddedObject }, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: { ...embeddedObject }, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectRealmDictionary(dictionary); - expect(dictionary).deep.equals({ value: 1 }); + expect(this.realm.objects(MixedAndEmbeddedSchema.name).length).equals(1); + expect(this.realm.objects(MixedSchema.name).length).equals(1); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(dictionary1).deep.equals({ mixed: 1 }); + expect(dictionary2).deep.equals({ mixed: 1 }); }); - it("a dictionary (input: Spread custom non-Realm object)", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { + it("can use the spread of custom non-Realm object", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { class CustomClass { constructor(public value: number) {} } const customObject = new CustomClass(1); - // Spread the embedded object in order to use its entries as a dictionary in Mixed. - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { ...customObject }, - }); + // Spread the custom object in order to use its entries as a dictionary in Mixed. + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { ...customObject }, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: { ...customObject }, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectRealmDictionary(dictionary); - expect(dictionary).deep.equals({ value: 1 }); + expect(this.realm.objects(MixedSchema.name).length).equals(1); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(dictionary1).deep.equals({ value: 1 }); + expect(dictionary2).deep.equals({ value: 1 }); }); - it("inserts list items via `push()`", function (this: RealmContext) { - const { value: list } = this.realm.write(() => { - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: [] }); - }); - expectRealmList(list); - expect(list.length).equals(0); + it("has nested lists of all primitive types", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { depth1: [[...primitiveTypesList, realmObject]] }; - this.realm.write(() => { - list.push(...flatListAllTypes); - list.push(this.realm.create(MixedSchema.name, unmanagedRealmObject)); + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectMatchingFlatList(list); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectDictionaryOfListsOfAllTypes(dictionary1); + expectDictionaryOfListsOfAllTypes(dictionary2); }); - it("inserts dictionary entries", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: {} }); - }); - expectRealmDictionary(dictionary); - expect(Object.keys(dictionary).length).equals(0); + it("has nested dictionaries of all primitive types", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { depth1: { depth2: { ...primitiveTypesDictionary, realmObject } } }; - this.realm.write(() => { - for (const key in flatDictionaryAllTypes) { - dictionary[key] = flatDictionaryAllTypes[key]; - } - dictionary.realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectMatchingFlatDictionary(dictionary); + + expect(this.realm.objects(MixedSchema.name).length).equals(2); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectDictionaryOfDictionariesOfAllTypes(dictionary1); + expectDictionaryOfDictionariesOfAllTypes(dictionary2); }); - }); - describe("Update", () => { - it("updates list items via property setters", function (this: RealmContext) { - const { value: list } = this.realm.write(() => { - const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: ["original", realmObject], - }); + it("has mix of nested collections of all types", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const unmanagedDictionary = buildDictionaryOfCollectionsOfAllTypes({ depth: 4 }); + + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectRealmList(list); - expect(list.length).equals(2); - expect(list[0]).equals("original"); - expect(list[1].value).equals("original"); - this.realm.write(() => { - list[0] = "updated"; - list[1].value = "updated"; + expect(this.realm.objects(MixedSchema.name).length).equals(1); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(1); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + it("inserts all primitive types via setter", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expect(list[0]).equals("updated"); - expect(list[1].value).equals("updated"); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); this.realm.write(() => { - list[0] = null; - list[1] = null; + for (const key in primitiveTypesDictionary) { + const value = primitiveTypesDictionary[key]; + dictionary1[key] = value; + dictionary2[key] = value; + } + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + dictionary1.realmObject = realmObject; + dictionary2.realmObject = realmObject; }); - expect(list.length).equals(2); - expect(list[0]).to.be.null; - expect(list[1]).to.be.null; + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); }); - it("updates dictionary entries via property setters", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { - const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { string: "original", realmObject }, - }); - }); - expectRealmDictionary(dictionary); - expect(Object.keys(dictionary).length).equals(2); - expect(dictionary.string).equals("original"); - expect(dictionary.realmObject.value).equals("original"); + it("inserts nested lists of all primitive types via setter", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }).dictionary; - this.realm.write(() => { - dictionary.string = "updated"; - dictionary.realmObject.value = "updated"; + return { dictionary1, dictionary2 }; }); - expect(dictionary.string).equals("updated"); - expect(dictionary.realmObject.value).equals("updated"); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); this.realm.write(() => { - dictionary.string = null; - dictionary.realmObject = null; + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [[...primitiveTypesList, realmObject]]; + + dictionary1.depth1 = unmanagedList; + dictionary2.depth1 = unmanagedList; }); - expect(Object.keys(dictionary).length).equals(2); - expect(dictionary.string).to.be.null; - expect(dictionary.realmObject).to.be.null; + expectDictionaryOfListsOfAllTypes(dictionary1); + expectDictionaryOfListsOfAllTypes(dictionary2); }); - }); - describe("Remove", () => { - it("removes list items via `remove()`", function (this: RealmContext) { - const { value: list } = this.realm.write(() => { - const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: ["original", realmObject], - }); + it("inserts nested dictionaries of all primitive types via setter", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectRealmList(list); - expect(list.length).equals(2); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); this.realm.write(() => { - list.remove(1); + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { depth2: { ...primitiveTypesDictionary, realmObject } }; + + dictionary1.depth1 = unmanagedDictionary; + dictionary2.depth1 = unmanagedDictionary; }); - expect(list.length).equals(1); - expect(list[0]).equals("original"); + expectDictionaryOfDictionariesOfAllTypes(dictionary1); + expectDictionaryOfDictionariesOfAllTypes(dictionary2); }); - it("removes dictionary entries via `remove()`", function (this: RealmContext) { - const { value: dictionary } = this.realm.write(() => { - const realmObject = this.realm.create(MixedSchema.name, { value: "original" }); - return this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { string: "original", realmObject }, - }); + it("inserts mix of nested collections of all types via setter", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }).dictionary; + + return { dictionary1, dictionary2 }; }); - expectRealmDictionary(dictionary); - expect(Object.keys(dictionary).length).equals(2); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); + const unmanagedDictionary = buildDictionaryOfCollectionsOfAllTypes({ depth: 4 }); this.realm.write(() => { - dictionary.remove("realmObject"); + for (const key in unmanagedDictionary) { + const value = unmanagedDictionary[key]; + dictionary1[key] = value; + dictionary2[key] = value; + } }); - expect(Object.keys(dictionary).length).equals(1); - expect(dictionary.string).equals("original"); - expect(dictionary.realmObject).to.be.undefined; + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); }); - }); - }); - describe("Filtering", () => { - it("filters by query path on list with different types", function (this: RealmContext) { - const expectedFilteredCount = 5; - const mixedList = [...flatListAllTypes]; - const nonExistentValue = "nonExistentValue"; + it("inserts mix of nested collections of all types via `set()` overloads", function (this: RealmContext) { + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }).dictionary; - this.realm.write(() => { - // Create 2 objects that should not pass the query string filter. - this.realm.create(MixedSchema.name, { value: "not a list" }); - mixedList.push(this.realm.create(MixedSchema.name, { value: "not a list" })); + return { dictionary1, dictionary2 }; + }); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); - // Create the objects that should pass the query string filter. - for (let count = 0; count < expectedFilteredCount; count++) { - this.realm.create(MixedSchema.name, { value: mixedList }); - } + const unmanagedDictionary = buildDictionaryOfCollectionsOfAllTypes({ depth: 4 }); + this.realm.write(() => { + dictionary1.set(unmanagedDictionary); + dictionary2.set(unmanagedDictionary); + }); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); }); - const objects = this.realm.objects(MixedSchema.name); - expect(objects.length).equals(expectedFilteredCount + 2); - - let index = 0; - for (const itemToMatch of mixedList) { - // Objects with a list item that matches the `itemToMatch` at the GIVEN index. - let filtered = objects.filtered(`value[${index}] == $0`, itemToMatch); - expect(filtered.length).equals(expectedFilteredCount); - filtered = objects.filtered(`value[${index}] == $0`, nonExistentValue); - expect(filtered.length).equals(0); + it("returns different reference for each access", function (this: RealmContext) { + const unmanagedDictionary: Record<string, unknown> = {}; + const { created1, created2 } = this.realm.write(() => { + const created1 = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedDictionary }); + const created2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: unmanagedDictionary, + }); - // Objects with a list item that matches the `itemToMatch` at ANY index. - filtered = objects.filtered(`value[*] == $0`, itemToMatch); - expect(filtered.length).equals(expectedFilteredCount); + return { created1, created2 }; + }); + expectRealmDictionary(created1.mixed); + expectRealmDictionary(created2.dictionary); + + expect(created1.mixed === unmanagedDictionary).to.be.false; + expect(created1.mixed === created1.mixed).to.be.false; + expect(Object.is(created1.mixed, created1.mixed)).to.be.false; + + expect(created2.dictionary === unmanagedDictionary).to.be.false; + expect(created2.dictionary === created2.dictionary).to.be.false; + expect(Object.is(created2.dictionary, created2.dictionary)).to.be.false; + + const { dictionary1, dictionary2 } = this.realm.write(() => { + const dictionary1 = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { key: unmanagedDictionary }, + }).mixed; + const dictionary2 = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: { key: unmanagedDictionary }, + }).dictionary; + + return { dictionary1, dictionary2 }; + }); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); - filtered = objects.filtered(`value[*] == $0`, nonExistentValue); - expect(filtered.length).equals(0); + expect(dictionary1.key === unmanagedDictionary).to.be.false; + expect(dictionary1.key === dictionary1.key).to.be.false; + expect(Object.is(dictionary1.key, dictionary1.key)).to.be.false; - index++; - } + expect(dictionary2.key === unmanagedDictionary).to.be.false; + expect(dictionary2.key === dictionary2.key).to.be.false; + expect(Object.is(dictionary2.key, dictionary2.key)).to.be.false; + }); }); - it("filters by query path on dictionary with different types", function (this: RealmContext) { - const expectedFilteredCount = 5; - const mixedDictionary = { ...flatDictionaryAllTypes }; - const nonExistentValue = "nonExistentValue"; - const nonExistentKey = "nonExistentKey"; + describe("Results", () => { + describe("from List", () => { + describe("snapshot()", () => { + it("has all primitive types", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [...primitiveTypesList, realmObject]; + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }); + }); + expectRealmList(list); + expectResultsOfAllTypes(list.snapshot()); + }); - this.realm.write(() => { - // Create 2 objects that should not pass the query string filter. - this.realm.create(MixedSchema.name, { value: "not a dictionary" }); - mixedDictionary.realmObject = this.realm.create(MixedSchema.name, { value: "not a dictionary" }); + it("has mix of nested collections of all types", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + const unmanagedList = buildListOfCollectionsOfAllTypes({ depth: 4 }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }); + }); + expectRealmList(list); + expectResultsOfAllTypes(list.snapshot()); + }); + }); - // Create the objects that should pass the query string filter. - for (let count = 0; count < expectedFilteredCount; count++) { - this.realm.create(MixedSchema.name, { value: mixedDictionary }); - } - }); - const objects = this.realm.objects(MixedSchema.name); - expect(objects.length).equals(expectedFilteredCount + 2); + describe("objects().filtered()", () => { + it("has all primitive types", function (this: RealmContext) { + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [...primitiveTypesList, realmObject]; + this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }); + }); - const insertedValues = Object.values(mixedDictionary); + const results = this.realm.objects<IMixedSchema>(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(2); - for (const key in mixedDictionary) { - const valueToMatch = mixedDictionary[key]; + const list = results[1].mixed; + expectListOfAllTypes(list); + }); - // Objects with a dictionary value that matches the `valueToMatch` at the GIVEN key. - let filtered = objects.filtered(`value['${key}'] == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + it("has mix of nested collections of all types", function (this: RealmContext) { + this.realm.write(() => { + const unmanagedList = buildListOfCollectionsOfAllTypes({ depth: 4 }); + this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedList }); + }); - filtered = objects.filtered(`value['${key}'] == $0`, nonExistentValue); - expect(filtered.length).equals(0); + const results = this.realm.objects<IMixedSchema>(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(1); - filtered = objects.filtered(`value['${nonExistentKey}'] == $0`, valueToMatch); - expect(filtered.length).equals(0); + const list = results[0].mixed; + expectListOfAllTypes(list); + }); + }); + }); - filtered = objects.filtered(`value.${key} == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + describe("from Dictionary", () => { + describe("objects().filtered()", () => { + it("has all primitive types", function (this: RealmContext) { + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { ...primitiveTypesDictionary, realmObject }; + this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedDictionary }); + }); + + const results = this.realm.objects<IMixedSchema>(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(2); + + const dictionary = results[1].mixed; + expectDictionaryOfAllTypes(dictionary); + }); - filtered = objects.filtered(`value.${key} == $0`, nonExistentValue); - expect(filtered.length).equals(0); + it("has mix of nested collections of all types", function (this: RealmContext) { + this.realm.write(() => { + const unmanagedDictionary = buildDictionaryOfCollectionsOfAllTypes({ depth: 4 }); + this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: unmanagedDictionary }); + }); - filtered = objects.filtered(`value.${nonExistentKey} == $0`, valueToMatch); - expect(filtered.length).equals(0); + const results = this.realm.objects<IMixedSchema>(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(1); - // Objects with a dictionary value that matches the `valueToMatch` at ANY key. - filtered = objects.filtered(`value[*] == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + const dictionary = results[0].mixed; + expectDictionaryOfAllTypes(dictionary); + }); + }); + }); + }); + }); - filtered = objects.filtered(`value[*] == $0`, nonExistentValue); - expect(filtered.length).equals(0); + describe("Update", () => { + describe("List", () => { + it("updates top-level item via setter", function (this: RealmContext) { + const { list, realmObject } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const { mixed: list } = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original"] }); + return { list, realmObject }; + }); + expectRealmList(list); + expect(list.length).equals(1); + expect(list[0]).equals("original"); - // Objects with a dictionary containing a key that matches `key`. - filtered = objects.filtered(`value.@keys == $0`, key); - expect(filtered.length).equals(expectedFilteredCount); + this.realm.write(() => { + list[0] = "updated"; + }); + expect(list.length).equals(1); + expect(list[0]).equals("updated"); - filtered = objects.filtered(`value.@keys == $0`, nonExistentKey); - expect(filtered.length).equals(0); + this.realm.write(() => { + list[0] = null; + }); + expect(list.length).equals(1); + expect(list[0]).to.be.null; - // Objects with a dictionary with the key `key` matching any of the values inserted. - filtered = objects.filtered(`value.${key} IN $0`, insertedValues); - expect(filtered.length).equals(expectedFilteredCount); + this.realm.write(() => { + list[0] = [[...primitiveTypesList, realmObject]]; + }); + expectListOfListsOfAllTypes(list); - filtered = objects.filtered(`value.${key} IN $0`, [nonExistentValue]); - expect(filtered.length).equals(0); - } - }); + this.realm.write(() => { + list[0] = { depth2: { ...primitiveTypesDictionary, realmObject } }; + }); + expectListOfDictionariesOfAllTypes(list); + }); + + it("updates nested item via setter", function (this: RealmContext) { + const { list, realmObject } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const { mixed: list } = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [["original"]] }); + return { list, realmObject }; + }); + expectRealmList(list); + const [nestedList] = list; + expectRealmList(nestedList); + expect(nestedList.length).equals(1); + expect(nestedList[0]).equals("original"); + + this.realm.write(() => { + nestedList[0] = "updated"; + }); + expect(nestedList.length).equals(1); + expect(nestedList[0]).equals("updated"); + + this.realm.write(() => { + nestedList[0] = null; + }); + expect(nestedList.length).equals(1); + expect(nestedList[0]).to.be.null; + + this.realm.write(() => { + nestedList[0] = [[...primitiveTypesList, realmObject]]; + }); + expectListOfListsOfAllTypes(nestedList); + + this.realm.write(() => { + nestedList[0] = { depth2: { ...primitiveTypesDictionary, realmObject } }; + }); + expectListOfDictionariesOfAllTypes(nestedList); + }); + + it("updates itself to a new list", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original1", "original2"] }); + }); + let list = created.mixed; + expectRealmList(list); + expect(list.length).equals(2); + expect(list[0]).equals("original1"); + expect(list[1]).equals("original2"); + + this.realm.write(() => { + created.mixed = ["updated"]; + }); + list = created.mixed; + expectRealmList(list); + expect(list.length).equals(1); + expect(list[0]).equals("updated"); + }); + + it("updates nested list to a new list", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [["original1", "original2"]], + }); + }); + expectRealmList(list); + expect(list.length).equals(1); + + let nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + expect(nestedList[0]).equals("original1"); + expect(nestedList[1]).equals("original2"); + + this.realm.write(() => { + list[0] = ["updated"]; + }); + nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(1); + expect(nestedList[0]).equals("updated"); + }); + + it("does not become invalidated when updated to a new list", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original"] }); + }); + const list = created.mixed; + expectRealmList(list); + + this.realm.write(() => { + created.mixed = ["updated"]; + }); + // Accessing `list` should not throw. + expect(list[0]).equals("updated"); + }); + + // TODO: Solve the "removeAll()" case for self-assignment. + it.skip("self assigns", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original1", "original2"] }); + }); + let list = created.mixed; + expectRealmList(list); + expect(list.length).equals(2); + expect(list[0]).equals("original1"); + expect(list[1]).equals("original2"); + + this.realm.write(() => { + /* eslint-disable-next-line no-self-assign */ + created.mixed = created.mixed; + }); + list = created.mixed; + expectRealmList(list); + expect(list.length).equals(2); + expect(list[0]).equals("original1"); + expect(list[1]).equals("original2"); + }); + + // TODO: Solve the "removeAll()" case for self-assignment. + it.skip("self assigns nested list", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [["original1", "original2"]], + }); + }); + expectRealmList(list); + expect(list.length).equals(1); + + let nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + expect(nestedList[0]).equals("original1"); + expect(nestedList[1]).equals("original2"); + + this.realm.write(() => { + /* eslint-disable-next-line no-self-assign */ + list[0] = list[0]; + }); + nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + expect(nestedList[0]).equals("original1"); + expect(nestedList[1]).equals("original2"); + }); + }); + + describe("Dictionary", () => { + it("updates top-level entry via setter", function (this: RealmContext) { + const { dictionary, realmObject } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const { mixed: dictionary } = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { depth1: "original" }, + }); + return { dictionary, realmObject }; + }); + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["depth1"]); + expect(dictionary.depth1).equals("original"); + + this.realm.write(() => { + dictionary.depth1 = "updated"; + }); + expectKeys(dictionary, ["depth1"]); + expect(dictionary.depth1).equals("updated"); + + this.realm.write(() => { + dictionary.depth1 = null; + }); + expectKeys(dictionary, ["depth1"]); + expect(dictionary.depth1).to.be.null; + + this.realm.write(() => { + dictionary.depth1 = [[...primitiveTypesList, realmObject]]; + }); + expectDictionaryOfListsOfAllTypes(dictionary); + + this.realm.write(() => { + dictionary.depth1 = { depth2: { ...primitiveTypesDictionary, realmObject } }; + }); + expectDictionaryOfDictionariesOfAllTypes(dictionary); + }); + + it("updates nested entry via setter", function (this: RealmContext) { + const { dictionary, realmObject } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const { mixed: dictionary } = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { depth1: { depth2: "original" } }, + }); + return { dictionary, realmObject }; + }); + expectRealmDictionary(dictionary); + const { depth1: nestedDictionary } = dictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["depth2"]); + expect(nestedDictionary.depth2).equals("original"); + + this.realm.write(() => { + nestedDictionary.depth2 = "updated"; + }); + expectKeys(nestedDictionary, ["depth2"]); + expect(nestedDictionary.depth2).equals("updated"); + + this.realm.write(() => { + nestedDictionary.depth2 = null; + }); + expectKeys(nestedDictionary, ["depth2"]); + expect(nestedDictionary.depth2).to.be.null; + + this.realm.write(() => { + nestedDictionary.depth2 = [[...primitiveTypesList, realmObject]]; + }); + expectKeys(nestedDictionary, ["depth2"]); + expectRealmList(nestedDictionary.depth2); + expectListOfAllTypes(nestedDictionary.depth2[0]); + + this.realm.write(() => { + nestedDictionary.depth2 = { depth3: { ...primitiveTypesDictionary, realmObject } }; + }); + expectKeys(nestedDictionary, ["depth2"]); + expectRealmDictionary(nestedDictionary.depth2); + expectDictionaryOfAllTypes(nestedDictionary.depth2.depth3); + }); + + it("updates itself to a new dictionary", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { key1: "original1", key2: "original2" }, + }); + }); + let dictionary = created.mixed; + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["key1", "key2"]); + expect(dictionary.key1).equals("original1"); + expect(dictionary.key2).equals("original2"); + + this.realm.write(() => { + created.mixed = { newKey: "updated" }; + }); + dictionary = created.mixed; + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["newKey"]); + expect(dictionary.newKey).equals("updated"); + }); + + it("updates nested dictionary to a new dictionary", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { nestedDictionary: { key1: "original1", key2: "original2" } }, + }); + }); + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["nestedDictionary"]); + + let nestedDictionary = dictionary.nestedDictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["key1", "key2"]); + expect(nestedDictionary.key1).equals("original1"); + expect(nestedDictionary.key2).equals("original2"); + + this.realm.write(() => { + dictionary.nestedDictionary = { newKey: "updated" }; + }); + nestedDictionary = dictionary.nestedDictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["newKey"]); + expect(nestedDictionary.newKey).equals("updated"); + }); + + it("does not become invalidated when updated to a new dictionary", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: { key: "original" } }); + }); + const dictionary = created.mixed; + expectRealmDictionary(dictionary); + + this.realm.write(() => { + created.mixed = { newKey: "updated" }; + }); + // Accessing `dictionary` should not throw. + expect(dictionary.newKey).equals("updated"); + }); + + // TODO: Solve the "removeAll()" case for self-assignment. + it.skip("self assigns", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { key1: "original1", key2: "original2" }, + }); + }); + let dictionary = created.mixed; + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["key1", "key2"]); + expect(dictionary.key1).equals("original1"); + expect(dictionary.key2).equals("original2"); + + this.realm.write(() => { + /* eslint-disable-next-line no-self-assign */ + created.mixed = created.mixed; + }); + dictionary = created.mixed; + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["key1", "key2"]); + expect(dictionary.key1).equals("original1"); + expect(dictionary.key2).equals("original2"); + }); + + // TODO: Solve the "removeAll()" case for self-assignment. + it.skip("self assigns nested dictionary", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { nestedDictionary: { key1: "original1", key2: "original2" } }, + }); + }); + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["nestedDictionary"]); + + let nestedDictionary = dictionary.nestedDictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["key1", "key2"]); + expect(nestedDictionary.key1).equals("original1"); + expect(nestedDictionary.key2).equals("original2"); + + this.realm.write(() => { + /* eslint-disable-next-line no-self-assign */ + dictionary.nestedDictionary = dictionary.nestedDictionary; + }); + nestedDictionary = dictionary.nestedDictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["key1", "key2"]); + expect(nestedDictionary.key1).equals("original1"); + expect(nestedDictionary.key2).equals("original2"); + }); + }); + }); + + describe("Remove", () => { + describe("List", () => { + it("removes top-level item via `remove()`", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { mixed: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: ["original", [], {}, realmObject], + }); + }); + expectRealmList(list); + expect(list.length).equals(4); + + // Remove each item one-by-one starting from the last. + + this.realm.write(() => { + list.remove(3); + }); + expect(list.length).equals(3); + expect(list[0]).equals("original"); + expectRealmList(list[1]); + expectRealmDictionary(list[2]); + + this.realm.write(() => { + list.remove(2); + }); + expect(list.length).equals(2); + expect(list[0]).equals("original"); + expectRealmList(list[1]); + + this.realm.write(() => { + list.remove(1); + }); + expect(list.length).equals(1); + expect(list[0]).equals("original"); + + this.realm.write(() => { + list.remove(0); + }); + expect(list.length).equals(0); + }); + + it("removes nested item via `remove()`", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { mixed: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [["original", [], {}, realmObject]], + }); + }); + expectRealmList(list); + const [nestedList] = list; + expectRealmList(nestedList); + expect(nestedList.length).equals(4); + + // Remove each item one-by-one starting from the last. + + this.realm.write(() => { + nestedList.remove(3); + }); + expect(nestedList.length).equals(3); + expect(nestedList[0]).equals("original"); + expectRealmList(nestedList[1]); + expectRealmDictionary(nestedList[2]); + + this.realm.write(() => { + nestedList.remove(2); + }); + expect(nestedList.length).equals(2); + expect(nestedList[0]).equals("original"); + expectRealmList(nestedList[1]); + + this.realm.write(() => { + nestedList.remove(1); + }); + expect(nestedList.length).equals(1); + expect(nestedList[0]).equals("original"); + + this.realm.write(() => { + nestedList.remove(0); + }); + expect(nestedList.length).equals(0); + }); + }); + + describe("Dictionary", () => { + it("removes top-level entry via `remove()`", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { mixed: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { string: "original", list: [], dictionary: {}, realmObject }, + }); + }); + expectRealmDictionary(dictionary); + expectKeys(dictionary, ["string", "list", "dictionary", "realmObject"]); + + // Remove each entry one-by-one. + + this.realm.write(() => { + dictionary.remove("realmObject"); + }); + expectKeys(dictionary, ["string", "list", "dictionary"]); + expect(dictionary.string).equals("original"); + expectRealmList(dictionary.list); + expectRealmDictionary(dictionary.dictionary); + + this.realm.write(() => { + dictionary.remove("dictionary"); + }); + expectKeys(dictionary, ["string", "list"]); + expect(dictionary.string).equals("original"); + expectRealmList(dictionary.list); + + this.realm.write(() => { + dictionary.remove("list"); + }); + expectKeys(dictionary, ["string"]); + expect(dictionary.string).equals("original"); + + this.realm.write(() => { + dictionary.remove("string"); + }); + expect(Object.keys(dictionary).length).equals(0); + }); + + it("removes nested entry via `remove()`", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, { mixed: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { depth1: { string: "original", list: [], dictionary: {}, realmObject } }, + }); + }); + expectRealmDictionary(dictionary); + const { depth1: nestedDictionary } = dictionary; + expectRealmDictionary(nestedDictionary); + expectKeys(nestedDictionary, ["string", "list", "dictionary", "realmObject"]); + + // Remove each entry one-by-one. + + this.realm.write(() => { + nestedDictionary.remove("realmObject"); + }); + expectKeys(nestedDictionary, ["string", "list", "dictionary"]); + expect(nestedDictionary.string).equals("original"); + expectRealmList(nestedDictionary.list); + expectRealmDictionary(nestedDictionary.dictionary); + + this.realm.write(() => { + nestedDictionary.remove("dictionary"); + }); + expectKeys(nestedDictionary, ["string", "list"]); + expect(nestedDictionary.string).equals("original"); + expectRealmList(nestedDictionary.list); + + this.realm.write(() => { + nestedDictionary.remove("list"); + }); + expectKeys(nestedDictionary, ["string"]); + expect(nestedDictionary.string).equals("original"); + + this.realm.write(() => { + nestedDictionary.remove("string"); + }); + expect(Object.keys(nestedDictionary).length).equals(0); + }); + }); + }); + + describe("JS collection methods", () => { + describe("List", () => { + it("pop()", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [[1, "string"], { key: "value" }], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + + // Remove last item of nested list. + let removed = this.realm.write(() => nestedList.pop()); + expect(removed).equals("string"); + removed = this.realm.write(() => nestedList.pop()); + expect(removed).equals(1); + expect(nestedList.length).equals(0); + removed = this.realm.write(() => nestedList.pop()); + expect(removed).to.be.undefined; + + // Remove last item of top-level list. + removed = this.realm.write(() => list.pop()); + expectRealmDictionary(removed); + removed = this.realm.write(() => list.pop()); + expectRealmList(removed); + expect(list.length).equals(0); + removed = this.realm.write(() => list.pop()); + expect(removed).to.be.undefined; + }); + + it("shift()", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [[1, "string"], { key: "value" }], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + + // Remove first item of nested list. + let removed = this.realm.write(() => nestedList.shift()); + expect(removed).equals(1); + removed = this.realm.write(() => nestedList.shift()); + expect(removed).equals("string"); + expect(nestedList.length).equals(0); + removed = this.realm.write(() => nestedList.shift()); + expect(removed).to.be.undefined; + + // Remove first item of top-level list. + removed = this.realm.write(() => list.shift()); + expectRealmList(removed); + removed = this.realm.write(() => list.shift()); + expectRealmDictionary(removed); + expect(list.length).equals(0); + removed = this.realm.write(() => list.shift()); + expect(removed).to.be.undefined; + }); + + it("unshift()", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }); + }); + expectRealmList(list); + expect(list.length).equals(0); + + // Insert item into top-level list. + let newLength = this.realm.write(() => list.unshift({})); + expect(newLength).equals(1); + expectRealmDictionary(list[0]); + newLength = this.realm.write(() => list.unshift([])); + expect(newLength).equals(2); + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(0); + + // Insert item into nested list. + newLength = this.realm.write(() => nestedList.unshift("string")); + expect(newLength).equals(1); + expect(nestedList[0]).equals("string"); + newLength = this.realm.write(() => nestedList.unshift(1)); + expect(newLength).equals(2); + expect(nestedList[0]).equals(1); + }); + + it("splice()", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [[1, "string"], { key: "value" }], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(2); + + // Remove all items from nested list. + let removed = this.realm.write(() => nestedList.splice(0)); + expect(removed).deep.equals([1, "string"]); + expect(nestedList.length).equals(0); + + // Insert items into nested list. + removed = this.realm.write(() => nestedList.splice(0, 0, 1, "string")); + expect(removed.length).equals(0); + expect(nestedList.length).equals(2); + expect(nestedList[0]).equals(1); + expect(nestedList[1]).equals("string"); + + // Remove all items from top-level list. + removed = this.realm.write(() => list.splice(0)); + expect(removed.length).equals(2); + expectRealmList(removed[0]); + expectRealmDictionary(removed[1]); + expect(list.length).equals(0); + + // Insert item into top-level list. + removed = this.realm.write(() => list.splice(0, 0, [1, "string"], { key: "value" })); + expect(removed.length).equals(0); + expect(list.length).equals(2); + expectRealmList(list[0]); + expectRealmDictionary(list[1]); + }); + + it("indexOf()", function (this: RealmContext) { + const NOT_FOUND = -1; + const unmanagedList = [1, "string"]; + const unmanagedDictionary = { key: "value" }; + + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [unmanagedList, unmanagedDictionary], + }); + }); + expectRealmList(list); + expect(list.length).equals(2); + + // Expect collections to behave as always being different references. + // Both the unmanaged and managed collections will yield "not found". + + expect(list.indexOf(unmanagedList)).equals(NOT_FOUND); + expect(list.indexOf(unmanagedDictionary)).equals(NOT_FOUND); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(list.indexOf(nestedList)).equals(NOT_FOUND); + + const nestedDictionary = list[1]; + expectRealmDictionary(nestedDictionary); + expect(list.indexOf(nestedDictionary)).equals(NOT_FOUND); + + expect(nestedList.indexOf(1)).equals(0); + expect(nestedList.indexOf("string")).equals(1); + }); + }); + + describe("Iterators", () => { + const unmanagedList: readonly unknown[] = [bool, double, string]; + const unmanagedDictionary: Readonly<Record<string, unknown>> = { bool, double, string }; + + /** + * Expects {@link collection} to contain the managed versions of: + * - {@link unmanagedList} - At index 0 (if list), or lowest key (if dictionary). + * - {@link unmanagedDictionary} - At index 1 (if list), or highest key (if dictionary). + */ + function expectIteratorValues(collection: Realm.List | Realm.Dictionary) { + const topIterator = collection.values(); + + // Expect a list as first item. + const nestedList = topIterator.next().value; + expectRealmList(nestedList); + + // Expect a dictionary as second item. + const nestedDictionary = topIterator.next().value; + expectRealmDictionary(nestedDictionary); + expect(topIterator.next().done).to.be.true; + + // Expect that the nested list iterator yields correct values. + let index = 0; + const nestedListIterator = nestedList.values(); + for (const value of nestedListIterator) { + expect(value).equals(unmanagedList[index++]); + } + expect(nestedListIterator.next().done).to.be.true; + + // Expect that the nested dictionary iterator yields correct values. + const nestedDictionaryIterator = nestedDictionary.values(); + expect(nestedDictionaryIterator.next().value).equals(unmanagedDictionary.bool); + expect(nestedDictionaryIterator.next().value).equals(unmanagedDictionary.double); + expect(nestedDictionaryIterator.next().value).equals(unmanagedDictionary.string); + expect(nestedDictionaryIterator.next().done).to.be.true; + } + + it("values() - list", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [unmanagedList, unmanagedDictionary], + }); + }); + expectRealmList(list); + expectIteratorValues(list); + }); + + it("values() - dictionary", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + // Use `a_` and `b_` prefixes to get the same order once retrieved internally. + mixed: { a_list: unmanagedList, b_dictionary: unmanagedDictionary }, + }); + }); + expectRealmDictionary(dictionary); + expectIteratorValues(dictionary); + }); + + /** + * Expects {@link collection} to contain the managed versions of: + * - {@link unmanagedList} - At index 0 (if list), or key `a_list` (if dictionary). + * - {@link unmanagedDictionary} - At index 1 (if list), or key `b_dictionary` (if dictionary). + */ + function expectIteratorEntries(collection: Realm.List | Realm.Dictionary) { + const usesIndex = collection instanceof Realm.List; + const topIterator = collection.entries(); + + // Expect a list as first item. + const [listIndexOrKey, nestedList] = topIterator.next().value; + expect(listIndexOrKey).equals(usesIndex ? 0 : "a_list"); + expectRealmList(nestedList); + + // Expect a dictionary as second item. + const [dictionaryIndexOrKey, nestedDictionary] = topIterator.next().value; + expect(dictionaryIndexOrKey).equals(usesIndex ? 1 : "b_dictionary"); + expectRealmDictionary(nestedDictionary); + expect(topIterator.next().done).to.be.true; + + // Expect that the nested list iterator yields correct entries. + let currentIndex = 0; + const nestedListIterator = nestedList.entries(); + for (const [index, item] of nestedListIterator) { + expect(index).equals(currentIndex); + expect(item).equals(unmanagedList[currentIndex++]); + } + expect(nestedListIterator.next().done).to.be.true; + + // Expect that the nested dictionary iterator yields correct entries. + const nestedDictionaryIterator = nestedDictionary.entries(); + expect(nestedDictionaryIterator.next().value).deep.equals(["bool", unmanagedDictionary.bool]); + expect(nestedDictionaryIterator.next().value).deep.equals(["double", unmanagedDictionary.double]); + expect(nestedDictionaryIterator.next().value).deep.equals(["string", unmanagedDictionary.string]); + expect(nestedDictionaryIterator.next().done).to.be.true; + } + + it("entries() - list", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: [unmanagedList, unmanagedDictionary], + }); + }); + expectRealmList(list); + expectIteratorEntries(list); + }); + + it("entries() - dictionary", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { + // Use `a_` and `b_` prefixes to get the same order once retrieved internally. + mixed: { a_list: unmanagedList, b_dictionary: unmanagedDictionary }, + }); + }); + expectRealmDictionary(dictionary); + expectIteratorEntries(dictionary); + }); + }); + }); + }); + + describe("Filtering", () => { + it("filters by query path on list of all primitive types", function (this: RealmContext) { + const list = [...primitiveTypesList]; + const nonExistentIndex = 10_000; + const nonExistentValue = "nonExistentValue"; + const expectedFilteredCount = 5; + + this.realm.write(() => { + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { mixed: "not a list" }); + list.push(this.realm.create(MixedSchema.name, { mixed: "not a list" })); + + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { mixed: list }); + } + }); + const objects = this.realm.objects(MixedSchema.name); + expect(objects.length).equals(expectedFilteredCount + 2); + + let index = 0; + for (const itemToMatch of list) { + // Objects with a list item that matches the `itemToMatch` at the GIVEN index. + + let filtered = objects.filtered(`mixed[${index}] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[${index}] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[${nonExistentIndex}] == $0`, itemToMatch); + expect(filtered.length).equals(0); + + // Objects with a list item that matches the `itemToMatch` at ANY index. + + filtered = objects.filtered(`mixed[*] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[${nonExistentIndex}][*] == $0`, itemToMatch); + expect(filtered.length).equals(0); + + index++; + } + + // Objects with a list containing the same number of items as the ones inserted. + + let filtered = objects.filtered(`mixed.@count == $0`, list.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@count == $0`, 0); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.@size == $0`, list.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@size == $0`, 0); + expect(filtered.length).equals(0); + + // Objects where `mixed` itself is of the given type. + + filtered = objects.filtered(`mixed.@type == 'collection'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@type == 'list'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@type == 'dictionary'`); + expect(filtered.length).equals(0); + + // Objects with a list containing an item of the given type. + + filtered = objects.filtered(`mixed[*].@type == 'null'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'bool'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'int'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'double'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'string'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'data'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'date'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'decimal128'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'objectId'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'uuid'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'link'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'collection'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[*].@type == 'list'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[*].@type == 'dictionary'`); + expect(filtered.length).equals(0); }); - }); - describe("Invalid operations", () => { - it("throws when creating a set (input: JS Set)", function (this: RealmContext) { + it("filters by query path on nested list of all primitive types", function (this: RealmContext) { + const list = [[[...primitiveTypesList]]]; + const nonExistentIndex = 10_000; + const nonExistentValue = "nonExistentValue"; + const expectedFilteredCount = 5; + this.realm.write(() => { - expect(() => this.realm.create(MixedSchema.name, { value: new Set() })).to.throw( - "Using a Set as a Mixed value is not supported", - ); - }); + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { mixed: "not a list" }); + list[0][0].push(this.realm.create(MixedSchema.name, { mixed: "not a list" })); + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { mixed: list }); + } + }); const objects = this.realm.objects(MixedSchema.name); - // TODO: Length should equal 0 when this PR is merged: https://github.com/realm/realm-js/pull/6356 - // expect(objects.length).equals(0); - expect(objects.length).equals(1); + expect(objects.length).equals(expectedFilteredCount + 2); + + let index = 0; + const nestedList = list[0][0]; + for (const itemToMatch of nestedList) { + // Objects with a nested list item that matches the `itemToMatch` at the GIVEN index. + + let filtered = objects.filtered(`mixed[0][0][${index}] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][${index}] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[0][0][${nonExistentIndex}] == $0`, itemToMatch); + expect(filtered.length).equals(0); + + // Objects with a nested list item that matches the `itemToMatch` at ANY index. + + filtered = objects.filtered(`mixed[0][0][*] == $0`, itemToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[0][${nonExistentIndex}][*] == $0`, itemToMatch); + expect(filtered.length).equals(0); + + index++; + } + + // Objects with a nested list containing the same number of items as the ones inserted. + + let filtered = objects.filtered(`mixed[0][0].@count == $0`, nestedList.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0].@count == $0`, 0); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[0][0].@size == $0`, nestedList.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0].@size == $0`, 0); + expect(filtered.length).equals(0); + + // Objects where `mixed[0][0]` itself is of the given type. + + filtered = objects.filtered(`mixed[0][0].@type == 'collection'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0].@type == 'list'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0].@type == 'dictionary'`); + expect(filtered.length).equals(0); + + // Objects with a nested list containing an item of the given type. + + filtered = objects.filtered(`mixed[0][0][*].@type == 'null'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'bool'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'int'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'double'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'string'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'data'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'date'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'decimal128'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'objectId'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'uuid'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'link'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'collection'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'list'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[0][0][*].@type == 'dictionary'`); + expect(filtered.length).equals(0); }); - it("throws when creating a set (input: Realm Set)", function (this: RealmContext) { + it("filters by query path on dictionary of all primitive types", function (this: RealmContext) { + const dictionary = { ...primitiveTypesDictionary }; + const nonExistentKey = "nonExistentKey"; + const nonExistentValue = "nonExistentValue"; + const expectedFilteredCount = 5; + this.realm.write(() => { - const { set } = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); - expect(set).instanceOf(Realm.Set); - expect(() => this.realm.create(MixedSchema.name, { value: set })).to.throw( - "Using a RealmSet as a Mixed value is not supported", - ); + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { mixed: "not a dictionary" }); + dictionary.realmObject = this.realm.create(MixedSchema.name, { mixed: "not a dictionary" }); + + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { mixed: dictionary }); + } }); + const objects = this.realm.objects(MixedSchema.name); + expect(objects.length).equals(expectedFilteredCount + 2); + + const insertedValues = Object.values(dictionary); + + for (const key in dictionary) { + const valueToMatch = dictionary[key]; + + // Objects with a dictionary value that matches the `valueToMatch` at the GIVEN key. + + let filtered = objects.filtered(`mixed['${key}'] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed['${key}'] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed['${nonExistentKey}'] == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + filtered = objects.filtered(`mixed.${key} == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.${key} == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.${nonExistentKey} == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + // Objects with a dictionary value that matches the `valueToMatch` at ANY key. + + filtered = objects.filtered(`mixed[*] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // Objects with a dictionary containing a key that matches the given key. + + filtered = objects.filtered(`mixed.@keys == $0`, key); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@keys == $0`, nonExistentKey); + expect(filtered.length).equals(0); + + // Objects with a dictionary value at the given key matching any of the values inserted. + + filtered = objects.filtered(`mixed.${key} IN $0`, insertedValues); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.${key} IN $0`, [nonExistentValue]); + expect(filtered.length).equals(0); + } + + // Objects with a dictionary containing the same number of keys as the ones inserted. + + let filtered = objects.filtered(`mixed.@count == $0`, insertedValues.length); + expect(filtered.length).equals(expectedFilteredCount); + filtered = objects.filtered(`mixed.@count == $0`, 0); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.@size == $0`, insertedValues.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@size == $0`, 0); + expect(filtered.length).equals(0); + + // Objects where `mixed` itself is of the given type. + + filtered = objects.filtered(`mixed.@type == 'collection'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@type == 'dictionary'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.@type == 'list'`); + expect(filtered.length).equals(0); + + // Objects with a dictionary containing a property of the given type. + + filtered = objects.filtered(`mixed[*].@type == 'null'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'bool'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'int'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'double'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'string'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'data'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'date'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'decimal128'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'objectId'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'uuid'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'link'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[*].@type == 'collection'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[*].@type == 'list'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed[*].@type == 'dictionary'`); + expect(filtered.length).equals(0); + }); + + it("filters by query path on nested dictionary of all primitive types", function (this: RealmContext) { + const dictionary = { depth1: { depth2: { ...primitiveTypesDictionary } } }; + const nonExistentKey = "nonExistentKey"; + const nonExistentValue = "nonExistentValue"; + const expectedFilteredCount = 5; + + this.realm.write(() => { + // Create 2 objects that should not pass the query string filter. + this.realm.create(MixedSchema.name, { mixed: "not a dictionary" }); + dictionary.depth1.depth2.realmObject = this.realm.create(MixedSchema.name, { mixed: "not a dictionary" }); + + // Create the objects that should pass the query string filter. + for (let count = 0; count < expectedFilteredCount; count++) { + this.realm.create(MixedSchema.name, { mixed: dictionary }); + } + }); const objects = this.realm.objects(MixedSchema.name); - // TODO: Length should equal 0 when this PR is merged: https://github.com/realm/realm-js/pull/6356 - // expect(objects.length).equals(0); - expect(objects.length).equals(1); + expect(objects.length).equals(expectedFilteredCount + 2); + + const nestedDictionary = dictionary.depth1.depth2; + const insertedValues = Object.values(nestedDictionary); + + for (const key in nestedDictionary) { + const valueToMatch = nestedDictionary[key]; + + // Objects with a nested dictionary value that matches the `valueToMatch` at the GIVEN key. + + let filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed['depth1']['depth2']['${nonExistentKey}'] == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.depth1.depth2.${nonExistentKey} == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + // Objects with a nested dictionary value that matches the `valueToMatch` at ANY key. + + filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, valueToMatch); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // Objects with a nested dictionary containing a key that matches the given key. + + filtered = objects.filtered(`mixed.depth1.depth2.@keys == $0`, key); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.@keys == $0`, nonExistentKey); + expect(filtered.length).equals(0); + + // Objects with a nested dictionary value at the given key matching any of the values inserted. + + filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, insertedValues); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, [nonExistentValue]); + expect(filtered.length).equals(0); + } + + // Objects with a nested dictionary containing the same number of keys as the ones inserted. + + let filtered = objects.filtered(`mixed.depth1.depth2.@count == $0`, insertedValues.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.@count == $0`, 0); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.depth1.depth2.@size == $0`, insertedValues.length); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.@size == $0`, 0); + expect(filtered.length).equals(0); + + // Objects where `depth2` itself is of the given type. + + filtered = objects.filtered(`mixed.depth1.depth2.@type == 'collection'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.@type == 'dictionary'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.@type == 'list'`); + expect(filtered.length).equals(0); + + // Objects with a nested dictionary containing a property of the given type. + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'null'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'bool'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'int'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'double'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'string'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'data'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'date'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'decimal128'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'objectId'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'uuid'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'link'`); + expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'collection'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'list'`); + expect(filtered.length).equals(0); + + filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'dictionary'`); + expect(filtered.length).equals(0); + }); + }); + + describe("Invalid operations", () => { + it("throws when creating a Mixed with a set", function (this: RealmContext) { + expect(() => { + this.realm.write(() => { + this.realm.create(MixedSchema.name, { mixed: new Set() }); + }); + }).to.throw("Using a Set as a Mixed value is not supported"); + expect(this.realm.objects(MixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + const { set } = this.realm.create(CollectionsOfMixedSchema.name, { set: [] }); + expect(set).instanceOf(Realm.Set); + this.realm.create(MixedSchema.name, { mixed: set }); + }); + }).to.throw("Using a RealmSet as a Mixed value is not supported"); + expect(this.realm.objects(MixedSchema.name).length).equals(0); + }); + + it("throws when creating a set with a list", function (this: RealmContext) { + expect(() => { + this.realm.write(() => { + const unmanagedList: unknown[] = []; + this.realm.create(CollectionsOfMixedSchema.name, { set: [unmanagedList] }); + }); + }).to.throw("Lists within a Set are not supported"); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + const { list } = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { list: [] }); + expectRealmList(list); + this.realm.create(CollectionsOfMixedSchema.name, { set: [list] }); + }); + }).to.throw("Lists within a Set are not supported"); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(0); + }); + + it("throws when creating a set with a dictionary", function (this: RealmContext) { + expect(() => { + this.realm.write(() => { + const unmanagedDictionary: Record<string, unknown> = {}; + this.realm.create(CollectionsOfMixedSchema.name, { set: [unmanagedDictionary] }); + }); + }).to.throw("Dictionaries within a Set are not supported"); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + const { dictionary } = this.realm.create<ICollectionsOfMixed>(CollectionsOfMixedSchema.name, { + dictionary: {}, + }); + expectRealmDictionary(dictionary); + this.realm.create(CollectionsOfMixedSchema.name, { set: [dictionary] }); + }); + }).to.throw("Dictionaries within a Set are not supported"); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(0); }); it("throws when updating a list item to a set", function (this: RealmContext) { const { set, list } = this.realm.write(() => { - const realmObjectWithSet = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); - const realmObjectWithMixed = this.realm.create<IMixedSchema>(MixedSchema.name, { value: ["original"] }); - return { set: realmObjectWithSet.set, list: realmObjectWithMixed.value }; + const set = this.realm.create(CollectionsOfMixedSchema.name, { set: [] }).set; + const list = this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original"] }).mixed; + return { set, list }; }); expectRealmList(list); expect(list[0]).equals("original"); @@ -796,120 +2700,209 @@ describe("Mixed", () => { it("throws when updating a dictionary entry to a set", function (this: RealmContext) { const { set, dictionary } = this.realm.write(() => { - const realmObjectWithSet = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }); - const realmObjectWithMixed = this.realm.create<IMixedSchema>(MixedSchema.name, { - value: { string: "original" }, - }); - return { set: realmObjectWithSet.set, dictionary: realmObjectWithMixed.value }; + const set = this.realm.create(CollectionsOfMixedSchema.name, { set: [int] }).set; + const dictionary = this.realm.create<IMixedSchema>(MixedSchema.name, { + mixed: { key: "original" }, + }).mixed; + return { set, dictionary }; }); expectRealmDictionary(dictionary); - expect(dictionary.string).equals("original"); + expect(dictionary.key).equals("original"); this.realm.write(() => { - expect(() => (dictionary.string = new Set())).to.throw("Using a Set as a Mixed value is not supported"); - expect(() => (dictionary.string = set)).to.throw("Using a RealmSet as a Mixed value is not supported"); + expect(() => (dictionary.key = new Set())).to.throw("Using a Set as a Mixed value is not supported"); + expect(() => (dictionary.key = set)).to.throw("Using a RealmSet as a Mixed value is not supported"); }); - expect(dictionary.string).equals("original"); + expect(dictionary.key).equals("original"); }); it("throws when creating a list or dictionary with an embedded object", function (this: RealmContext) { - this.realm.write(() => { - // Create an object with an embedded object property. - const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { - embeddedObject: { value: 1 }, + // Create an object with an embedded object property. + const { embeddedObject } = this.realm.write(() => { + return this.realm.create(MixedAndEmbeddedSchema.name, { + embeddedObject: { mixed: 1 }, }); - expect(embeddedObject).instanceOf(Realm.Object); - - // Create two objects with the Mixed property (`value`) being a list and - // dictionary (respectively) containing the reference to the embedded object. - expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: [embeddedObject] })).to.throw( - "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", - ); - expect(() => this.realm.create(MixedAndEmbeddedSchema.name, { mixedValue: { embeddedObject } })).to.throw( - "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", - ); }); + expect(embeddedObject).instanceOf(Realm.Object); const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); - // TODO: Length should equal 1 when this PR is merged: https://github.com/realm/realm-js/pull/6356 - // expect(objects.length).equals(1); - expect(objects.length).equals(3); + expect(objects.length).equals(1); + + // Create an object with the Mixed property as a list containing the embedded object. + expect(() => { + this.realm.write(() => { + this.realm.create(MixedAndEmbeddedSchema.name, { mixed: [embeddedObject] }); + }); + }).to.throw("Using an embedded object (EmbeddedObject) as a Mixed value is not supported"); + expect(objects.length).equals(1); + + // Create an object with the Mixed property as a dictionary containing the embedded object. + expect(() => { + this.realm.write(() => { + this.realm.create(MixedAndEmbeddedSchema.name, { mixed: { embeddedObject } }); + }); + }).to.throw("Using an embedded object (EmbeddedObject) as a Mixed value is not supported"); + expect(objects.length).equals(1); }); it("throws when setting a list or dictionary item to an embedded object", function (this: RealmContext) { this.realm.write(() => { // Create an object with an embedded object property. const { embeddedObject } = this.realm.create(MixedAndEmbeddedSchema.name, { - embeddedObject: { value: 1 }, + embeddedObject: { mixed: 1 }, }); expect(embeddedObject).instanceOf(Realm.Object); - // Create two objects with the Mixed property (`value`) - // being an empty list and dictionary (respectively). - const { mixedValue: list } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { - mixedValue: [], + // Create an object with the Mixed property as a list. + const { mixed: list } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { + mixed: ["original"], }); expectRealmList(list); - const { mixedValue: dictionary } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { - mixedValue: {}, + // Create an object with the Mixed property as a dictionary. + const { mixed: dictionary } = this.realm.create<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name, { + mixed: { key: "original" }, }); expectRealmDictionary(dictionary); + // Assign the embedded object to the collections. expect(() => (list[0] = embeddedObject)).to.throw( "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", ); - expect( - () => (dictionary.prop = embeddedObject), + expect(() => (dictionary.key = embeddedObject)).to.throw( "Using an embedded object (EmbeddedObject) as a Mixed value is not supported", ); }); const objects = this.realm.objects<IMixedAndEmbedded>(MixedAndEmbeddedSchema.name); expect(objects.length).equals(3); - // Check that the list and dictionary are still empty. - expect((objects[1].mixedValue as Realm.List<any>).length).equals(0); - expect(Object.keys(objects[2].mixedValue as Realm.Dictionary<any>).length).equals(0); + + // Check that the list and dictionary are unchanged. + const list = objects[1].mixed; + expectRealmList(list); + expect(list[0]).equals("original"); + + const dictionary = objects[2].mixed; + expectRealmDictionary(dictionary); + expect(dictionary.key).equals("original"); }); it("throws when setting a list or dictionary outside a transaction", function (this: RealmContext) { const created = this.realm.write(() => { - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: "original" }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: "original" }); }); - expect(created.value).equals("original"); - expect(() => (created.value = ["a list item"])).to.throw( + expect(created.mixed).equals("original"); + expect(() => (created.mixed = ["a list item"])).to.throw( "Cannot modify managed objects outside of a write transaction", ); - expect(() => (created.value = { key: "a dictionary value" })).to.throw( + expect(() => (created.mixed = { key: "a dictionary value" })).to.throw( "Cannot modify managed objects outside of a write transaction", ); - expect(created.value).equals("original"); + expect(created.mixed).equals("original"); + }); + + it("throws when setting a list item out of bounds", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + // Create an empty list as the Mixed value. + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [] }); + }); + expectRealmList(list); + expect(list.length).equals(0); + + expect(() => { + this.realm.write(() => { + list[0] = "primitive"; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed' when empty"); + + expect(() => { + this.realm.write(() => { + list[0] = []; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed' when empty"); + + expect(() => { + this.realm.write(() => { + list[0] = {}; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed' when empty"); + }); + + it("throws when setting a nested list item out of bounds", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + // Create a list containing an empty list as the Mixed value. + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [[]] }); + }); + expectRealmList(list); + expect(list.length).equals(1); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(0); + + expect(() => { + this.realm.write(() => { + nestedList[0] = "primitive"; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = []; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = {}; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + }); + + it("throws when assigning to list snapshot (Results)", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original"] }); + }); + expectRealmList(list); + + const results = list.snapshot(); + expectRealmResults(results); + expect(results.length).equals(1); + expect(results[0]).equals("original"); + + expect(() => { + this.realm.write(() => { + results[0] = "updated"; + }); + }).to.throw("Modifying a Results collection is not supported"); + expect(results.length).equals(1); + expect(results[0]).equals("original"); }); it("invalidates the list when removed", function (this: RealmContext) { const created = this.realm.write(() => { - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: [1] }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: [1] }); }); - const list = created.value; + const list = created.mixed; expectRealmList(list); this.realm.write(() => { - created.value = null; + created.mixed = null; }); - expect(created.value).to.be.null; + expect(created.mixed).to.be.null; expect(() => list[0]).to.throw("List is no longer valid"); }); it("invalidates the dictionary when removed", function (this: RealmContext) { const created = this.realm.write(() => { - return this.realm.create<IMixedSchema>(MixedSchema.name, { value: { prop: 1 } }); + return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: { key: "original" } }); }); - const dictionary = created.value; + const dictionary = created.mixed; expectRealmDictionary(dictionary); this.realm.write(() => { - created.value = null; + created.mixed = null; }); - expect(created.value).to.be.null; - expect(() => dictionary.prop).to.throw("This collection is no more"); + expect(created.mixed).to.be.null; + expect(() => dictionary.key).to.throw("This collection is no more"); }); }); }); @@ -923,18 +2916,18 @@ describe("Mixed", () => { const uint8Buffer1 = new Uint8Array(uint8Values1).buffer; const uint8Buffer2 = new Uint8Array(uint8Values2).buffer; this.realm.write(() => { - this.realm.create("MixedClass", { value: uint8Buffer1 }); + this.realm.create("MixedClass", { mixed: uint8Buffer1 }); }); let mixedObjects = this.realm.objects<IMixedSchema>("MixedClass"); - let returnedData = [...new Uint8Array(mixedObjects[0].value as Iterable<number>)]; + let returnedData = [...new Uint8Array(mixedObjects[0].mixed as Iterable<number>)]; expect(returnedData).eql(uint8Values1); this.realm.write(() => { - mixedObjects[0].value = uint8Buffer2; + mixedObjects[0].mixed = uint8Buffer2; }); mixedObjects = this.realm.objects<IMixedSchema>("MixedClass"); - returnedData = [...new Uint8Array(mixedObjects[0].value as Iterable<number>)]; + returnedData = [...new Uint8Array(mixedObjects[0].mixed as Iterable<number>)]; expect(returnedData).eql(uint8Values2); this.realm.write(() => { @@ -943,10 +2936,10 @@ describe("Mixed", () => { // Test with empty array this.realm.write(() => { - this.realm.create<IMixedSchema>("MixedClass", { value: new Uint8Array(0) }); + this.realm.create<IMixedSchema>("MixedClass", { mixed: new Uint8Array(0) }); }); - const emptyArrayBuffer = mixedObjects[0].value; + const emptyArrayBuffer = mixedObjects[0].mixed; expect(emptyArrayBuffer).instanceOf(ArrayBuffer); expect((emptyArrayBuffer as ArrayBuffer).byteLength).equals(0); @@ -958,11 +2951,11 @@ describe("Mixed", () => { const uint16Values = [0, 512, 256, 65535]; const uint16Buffer = new Uint16Array(uint16Values).buffer; this.realm.write(() => { - this.realm.create("MixedClass", { value: uint16Buffer }); + this.realm.create("MixedClass", { mixed: uint16Buffer }); }); const uint16Objects = this.realm.objects<IMixedSchema>("MixedClass"); - returnedData = [...new Uint16Array(uint16Objects[0].value as Iterable<number>)]; + returnedData = [...new Uint16Array(uint16Objects[0].mixed as Iterable<number>)]; expect(returnedData).eql(uint16Values); this.realm.write(() => { @@ -973,11 +2966,11 @@ describe("Mixed", () => { const uint32Values = [0, 121393, 121393, 317811, 514229, 4294967295]; const uint32Buffer = new Uint32Array(uint32Values).buffer; this.realm.write(() => { - this.realm.create("MixedClass", { value: uint32Buffer }); + this.realm.create("MixedClass", { mixed: uint32Buffer }); }); const uint32Objects = this.realm.objects<IMixedSchema>("MixedClass"); - returnedData = [...new Uint32Array(uint32Objects[0].value as Iterable<number>)]; + returnedData = [...new Uint32Array(uint32Objects[0].mixed as Iterable<number>)]; expect(returnedData).eql(uint32Values); this.realm.close(); diff --git a/integration-tests/tests/src/tests/observable.ts b/integration-tests/tests/src/tests/observable.ts index 54ca6e92bd..37777d2f00 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -238,6 +238,14 @@ async function expectListenerRemoval({ addListener, removeListener, update }: Li await handle; } +function expectRealmList(value: unknown): asserts value is Realm.List<any> { + expect(value).instanceOf(Realm.List); +} + +function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary<any> { + expect(value).instanceOf(Realm.Dictionary); +} + function noop() { /* tumbleweed */ } @@ -1379,306 +1387,576 @@ describe("Observable", () => { describe("Collections in Mixed", () => { class ObjectWithMixed extends Realm.Object<ObjectWithMixed> { - mixedValue!: Realm.Types.Mixed; + mixed!: Realm.Types.Mixed; static schema: ObjectSchema = { name: "ObjectWithMixed", properties: { - mixedValue: "mixed", + mixed: "mixed", }, }; } - type CollectionsInMixedContext = { + type CollectionsInMixedContext = RealmContext & { objectWithList: Realm.Object<ObjectWithMixed> & ObjectWithMixed; objectWithDictionary: Realm.Object<ObjectWithMixed> & ObjectWithMixed; - list: Realm.List<any>; - dictionary: Realm.Dictionary<any>; - } & RealmContext; + }; openRealmBeforeEach({ schema: [ObjectWithMixed] }); beforeEach(function (this: CollectionsInMixedContext) { - this.objectWithList = this.realm.write(() => { - return this.realm.create(ObjectWithMixed, { mixedValue: [] }); - }); - this.list = this.objectWithList.mixedValue as Realm.List<any>; - expect(this.list).instanceOf(Realm.List); - - this.objectWithDictionary = this.realm.write(() => { - return this.realm.create(ObjectWithMixed, { mixedValue: {} }); - }); - this.dictionary = this.objectWithDictionary.mixedValue as Realm.Dictionary<any>; - expect(this.dictionary).instanceOf(Realm.Dictionary); + this.objectWithList = this.realm.write(() => this.realm.create(ObjectWithMixed, { mixed: [] })); + this.objectWithDictionary = this.realm.write(() => this.realm.create(ObjectWithMixed, { mixed: {} })); }); describe("Collection notifications", () => { - it("fires when inserting to top-level list", async function (this: CollectionsInMixedContext) { - await expectCollectionNotifications(this.list, undefined, [ - EMPTY_COLLECTION_CHANGESET, - () => { - this.realm.write(() => { - this.list.push("Amy"); - this.list.push("Mary"); - this.list.push("John"); - }); - }, - { - deletions: [], - insertions: [0, 1, 2], - newModifications: [], - oldModifications: [], - }, - ]); - }); + describe("List", () => { + it("fires when inserting, updating, and deleting at top-level", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); - it("fires when inserting to top-level dictionary", async function (this: CollectionsInMixedContext) { - await expectDictionaryNotifications(this.dictionary, undefined, [ - EMPTY_DICTIONARY_CHANGESET, - () => { - this.realm.write(() => { - this.dictionary.amy = "Amy"; - this.dictionary.mary = "Mary"; - this.dictionary.john = "John"; - }); - }, - { - deletions: [], - insertions: ["amy", "mary", "john"], - modifications: [], - }, - ]); - }); + await expectCollectionNotifications(list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + // Insert items. + () => { + this.realm.write(() => { + list.push("Amy"); + list.push("Mary"); + list.push("John"); + }); + }, + { + deletions: [], + insertions: [0, 1, 2], + newModifications: [], + oldModifications: [], + }, + // Update items. + () => { + this.realm.write(() => { + list[0] = "Updated Amy"; + list[2] = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0, 2], + oldModifications: [0, 2], + }, + // Delete items. + () => { + this.realm.write(() => { + list.remove(2); + }); + }, + { + deletions: [2], + insertions: [], + newModifications: [], + oldModifications: [], + }, + ]); + }); - it("fires when updating top-level list", async function (this: CollectionsInMixedContext) { - await expectCollectionNotifications(this.list, undefined, [ - EMPTY_COLLECTION_CHANGESET, - () => { - this.realm.write(() => { - this.list.push("Amy"); - this.list.push("Mary"); - this.list.push("John"); - }); - }, - { - deletions: [], - insertions: [0, 1, 2], - newModifications: [], - oldModifications: [], - }, - () => { - this.realm.write(() => { - this.list[0] = "Updated Amy"; - this.list[2] = "Updated John"; - }); - }, - { - deletions: [], - insertions: [], - newModifications: [0, 2], - oldModifications: [0, 2], - }, - ]); - }); + it("fires when inserting, updating, and deleting in nested list", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); - it("fires when updating top-level dictionary", async function (this: CollectionsInMixedContext) { - await expectDictionaryNotifications(this.dictionary, undefined, [ - EMPTY_DICTIONARY_CHANGESET, - () => { - this.realm.write(() => { - this.dictionary.amy = "Amy"; - this.dictionary.mary = "Mary"; - this.dictionary.john = "John"; - }); - }, - { - deletions: [], - insertions: ["amy", "mary", "john"], - modifications: [], - }, - () => { - this.realm.write(() => { - this.dictionary.amy = "Updated Amy"; - this.dictionary.john = "Updated John"; - }); - }, - { - deletions: [], - insertions: [], - modifications: ["amy", "john"], - }, - ]); + await expectCollectionNotifications(list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + // Insert nested list. + () => { + this.realm.write(() => { + list.push([]); + }); + expectRealmList(list[0]); + }, + { + deletions: [], + insertions: [0], + newModifications: [], + oldModifications: [], + }, + // Insert items into nested list. + () => { + this.realm.write(() => { + const [nestedList] = list; + nestedList.push("Amy"); + nestedList.push("Mary"); + nestedList.push("John"); + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + // Update items in nested list. + () => { + this.realm.write(() => { + const [nestedList] = list; + nestedList[0] = "Updated Amy"; + nestedList[2] = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + // Delete items from nested list. + () => { + this.realm.write(() => { + list[0].remove(0); + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + ]); + }); + + it("fires when inserting, updating, and deleting in nested dictionary", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + await expectCollectionNotifications(list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + // Insert nested dictionary. + () => { + this.realm.write(() => { + list.push({}); + }); + expectRealmDictionary(list[0]); + }, + { + deletions: [], + insertions: [0], + newModifications: [], + oldModifications: [], + }, + // Insert items into nested dictionary. + () => { + this.realm.write(() => { + const [nestedDictionary] = list; + nestedDictionary.amy = "Amy"; + nestedDictionary.mary = "Mary"; + nestedDictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + // Update items in nested dictionary. + () => { + this.realm.write(() => { + const [nestedDictionary] = list; + nestedDictionary.amy = "Updated Amy"; + nestedDictionary.john = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + // Delete items from nested dictionary. + () => { + this.realm.write(() => { + list[0].remove("amy"); + }); + }, + { + deletions: [], + insertions: [], + newModifications: [0], + oldModifications: [0], + }, + ]); + }); + + it("does not fire when updating object at top-level", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + const realmObjectInList = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixed: "original" }); + }); + + await expectCollectionNotifications(list, undefined, [ + EMPTY_COLLECTION_CHANGESET, + // Insert the object into the list. + () => { + this.realm.write(() => { + list.push(realmObjectInList); + }); + expect(list.length).equals(1); + expect(realmObjectInList.mixed).equals("original"); + }, + { + deletions: [], + insertions: [0], + newModifications: [], + oldModifications: [], + }, + // Update the object and don't expect a changeset. + () => { + this.realm.write(() => { + realmObjectInList.mixed = "updated"; + }); + expect(realmObjectInList.mixed).equals("updated"); + }, + ]); + }); }); - it("fires when deleting from top-level list", async function (this: CollectionsInMixedContext) { - await expectCollectionNotifications(this.list, undefined, [ - EMPTY_COLLECTION_CHANGESET, - () => { - this.realm.write(() => { - this.list.push("Amy"); - this.list.push("Mary"); - this.list.push("John"); - }); - }, - { - deletions: [], - insertions: [0, 1, 2], - newModifications: [], - oldModifications: [], - }, - () => { - this.realm.write(() => { - this.list.remove(2); - }); - }, - { - deletions: [2], - insertions: [], - newModifications: [], - oldModifications: [], - }, - ]); + describe("Dictionary", () => { + it("fires when inserting, updating, and deleting at top-level", async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + + await expectDictionaryNotifications(dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + // Insert items. + () => { + this.realm.write(() => { + dictionary.amy = "Amy"; + dictionary.mary = "Mary"; + dictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: ["amy", "mary", "john"], + modifications: [], + }, + // Update items. + () => { + this.realm.write(() => { + dictionary.amy = "Updated Amy"; + dictionary.john = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + modifications: ["amy", "john"], + }, + // Delete items. + () => { + this.realm.write(() => { + dictionary.remove("mary"); + }); + }, + { + deletions: ["mary"], + insertions: [], + modifications: [], + }, + ]); + }); + + it("fires when inserting, updating, and deleting in nested list", async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + + await expectDictionaryNotifications(dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + // Insert nested list. + () => { + this.realm.write(() => { + dictionary.nestedList = []; + }); + expectRealmList(dictionary.nestedList); + }, + { + deletions: [], + insertions: ["nestedList"], + modifications: [], + }, + // Insert items into nested list. + () => { + this.realm.write(() => { + const { nestedList } = dictionary; + nestedList.push("Amy"); + nestedList.push("Mary"); + nestedList.push("John"); + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedList"], + }, + // Update items in nested list. + () => { + this.realm.write(() => { + const { nestedList } = dictionary; + nestedList[0] = "Updated Amy"; + nestedList[2] = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedList"], + }, + // Delete items from nested list. + () => { + this.realm.write(() => { + dictionary.nestedList.remove(1); + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedList"], + }, + ]); + }); + + it("fires when inserting, updating, and deleting in nested dictionary", async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + + await expectDictionaryNotifications(dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + // Insert nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary = {}; + }); + expectRealmDictionary(dictionary.nestedDictionary); + }, + { + deletions: [], + insertions: ["nestedDictionary"], + modifications: [], + }, + // Insert items into nested dictionary. + () => { + this.realm.write(() => { + const { nestedDictionary } = dictionary; + nestedDictionary.amy = "Amy"; + nestedDictionary.mary = "Mary"; + nestedDictionary.john = "John"; + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedDictionary"], + }, + // Update items in nested dictionary. + () => { + this.realm.write(() => { + const { nestedDictionary } = dictionary; + nestedDictionary.amy = "Updated Amy"; + nestedDictionary.john = "Updated John"; + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedDictionary"], + }, + // Delete items from nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary.remove("mary"); + }); + }, + { + deletions: [], + insertions: [], + modifications: ["nestedDictionary"], + }, + ]); + }); + + it("does not fire when updating object at top-level", async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + + const realmObjectInDictionary = this.realm.write(() => { + return this.realm.create(ObjectWithMixed, { mixed: "original" }); + }); + + await expectDictionaryNotifications(dictionary, undefined, [ + EMPTY_DICTIONARY_CHANGESET, + // Insert the object into the dictionary. + () => { + this.realm.write(() => { + dictionary.realmObject = realmObjectInDictionary; + }); + expect(realmObjectInDictionary.mixed).equals("original"); + }, + { + deletions: [], + insertions: ["realmObject"], + modifications: [], + }, + // Update the object and don't expect a changeset. + () => { + this.realm.write(() => { + realmObjectInDictionary.mixed = "updated"; + }); + expect(realmObjectInDictionary.mixed).equals("updated"); + }, + ]); + }); }); + }); - it("fires when deleting from top-level dictionary", async function (this: CollectionsInMixedContext) { - await expectDictionaryNotifications(this.dictionary, undefined, [ - EMPTY_DICTIONARY_CHANGESET, - () => { - this.realm.write(() => { - this.dictionary.amy = "Amy"; - this.dictionary.mary = "Mary"; - this.dictionary.john = "John"; - }); - }, - { - deletions: [], - insertions: ["amy", "mary", "john"], - modifications: [], - }, + describe("Object notifications", () => { + it("fires when inserting, updating, and deleting in top-level list", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + await expectObjectNotifications(this.objectWithList, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Insert list item. () => { this.realm.write(() => { - this.dictionary.remove("mary"); + list.push("Amy"); }); }, - { - deletions: ["mary"], - insertions: [], - modifications: [], - }, - ]); - }); - - it("does not fire when updating object in top-level list", async function (this: CollectionsInMixedContext) { - const realmObjectInList = this.realm.write(() => { - return this.realm.create(ObjectWithMixed, { mixedValue: "original" }); - }); - - await expectCollectionNotifications(this.list, undefined, [ - EMPTY_COLLECTION_CHANGESET, + { deleted: false, changedProperties: ["mixed"] }, + // Update list item. () => { this.realm.write(() => { - this.list.push(realmObjectInList); + list[0] = "Updated Amy"; }); - expect(this.list.length).equals(1); - expect(realmObjectInList.mixedValue).equals("original"); - }, - { - deletions: [], - insertions: [0], - newModifications: [], - oldModifications: [], }, + { deleted: false, changedProperties: ["mixed"] }, + // Delete list item. () => { this.realm.write(() => { - realmObjectInList.mixedValue = "updated"; + list.remove(0); }); - expect(realmObjectInList.mixedValue).equals("updated"); }, + { deleted: false, changedProperties: ["mixed"] }, ]); }); - it("does not fire when updating object in top-level dictionary", async function (this: CollectionsInMixedContext) { - const realmObjectInDictionary = this.realm.write(() => { - return this.realm.create(ObjectWithMixed, { mixedValue: "original" }); - }); + it("fires when inserting, updating, and deleting in nested list", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); - await expectDictionaryNotifications(this.dictionary, undefined, [ - EMPTY_DICTIONARY_CHANGESET, + await expectObjectNotifications(this.objectWithList, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Insert nested list. () => { this.realm.write(() => { - this.dictionary.realmObject = realmObjectInDictionary; + list.push([]); }); - expect(realmObjectInDictionary.mixedValue).equals("original"); - }, - { - deletions: [], - insertions: ["realmObject"], - modifications: [], + expectRealmList(list[0]); }, + { deleted: false, changedProperties: ["mixed"] }, + // Insert item into nested list. () => { this.realm.write(() => { - realmObjectInDictionary.mixedValue = "updated"; + list[0].push("Amy"); }); - expect(realmObjectInDictionary.mixedValue).equals("updated"); }, - ]); - }); - }); - - describe("Object notifications", () => { - it("fires when inserting, updating, and deleting in top-level list", async function (this: CollectionsInMixedContext) { - await expectObjectNotifications(this.objectWithList, undefined, [ - EMPTY_OBJECT_CHANGESET, - // Insert list item. + { deleted: false, changedProperties: ["mixed"] }, + // Update item in nested list. () => { this.realm.write(() => { - this.list.push("Amy"); + list[0][0] = "Updated Amy"; }); }, - { deleted: false, changedProperties: ["mixedValue"] }, - // Update list item. + { deleted: false, changedProperties: ["mixed"] }, + // Delete item from nested list. () => { this.realm.write(() => { - this.list[0] = "Updated Amy"; + list[0].remove(0); }); }, - { deleted: false, changedProperties: ["mixedValue"] }, - // Delete list item. - () => { - this.realm.write(() => { - this.list.remove(0); - }); - }, - { deleted: false, changedProperties: ["mixedValue"] }, + { deleted: false, changedProperties: ["mixed"] }, ]); }); it("fires when inserting, updating, and deleting in top-level dictionary", async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + await expectObjectNotifications(this.objectWithDictionary, undefined, [ EMPTY_OBJECT_CHANGESET, // Insert dictionary item. () => { this.realm.write(() => { - this.dictionary.amy = "Amy"; + dictionary.amy = "Amy"; }); }, - { deleted: false, changedProperties: ["mixedValue"] }, + { deleted: false, changedProperties: ["mixed"] }, // Update dictionary item. () => { this.realm.write(() => { - this.dictionary.amy = "Updated Amy"; + dictionary.amy = "Updated Amy"; }); }, - { deleted: false, changedProperties: ["mixedValue"] }, + { deleted: false, changedProperties: ["mixed"] }, // Delete dictionary item. () => { this.realm.write(() => { - this.dictionary.remove("amy"); + dictionary.remove("amy"); }); }, - { deleted: false, changedProperties: ["mixedValue"] }, + { deleted: false, changedProperties: ["mixed"] }, ]); }); + + for (const keyPath of [undefined, "mixed"]) { + const namePostfix = keyPath ? "(using key-path)" : ""; + it(`fires when inserting, updating, and deleting in nested dictionary ${namePostfix}`, async function (this: CollectionsInMixedContext) { + const dictionary = this.objectWithDictionary.mixed; + expectRealmDictionary(dictionary); + + await expectObjectNotifications(this.objectWithDictionary, keyPath, [ + EMPTY_OBJECT_CHANGESET, + // Insert nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary = {}; + }); + expectRealmDictionary(dictionary.nestedDictionary); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Insert item into nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary.amy = "Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Update item in nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary.amy = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Delete item from nested dictionary. + () => { + this.realm.write(() => { + dictionary.nestedDictionary.remove("amy"); + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + ]); + }); + } }); }); }); diff --git a/integration-tests/tests/src/tests/results.ts b/integration-tests/tests/src/tests/results.ts index 8f37f66de9..1ca46597ea 100644 --- a/integration-tests/tests/src/tests/results.ts +++ b/integration-tests/tests/src/tests/results.ts @@ -186,15 +186,15 @@ describe("Results", () => { expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[-1] = { doubleCol: 0 }; - }).throws("Index -1 cannot be less than zero."); + }).throws("Modifying a Results collection is not supported"); expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[0] = { doubleCol: 0 }; - }).throws("Assigning into a Results is not supported"); + }).throws("Modifying a Results collection is not supported"); expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[1] = { doubleCol: 0 }; - }).throws("Assigning into a Results is not supported"); + }).throws("Modifying a Results collection is not supported"); expect(() => { objects.length = 0; }).throws("Cannot assign to read only property 'length'"); diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index 9ec1234bdb..bab61c6703 100644 --- a/packages/realm/bindgen/js_opt_in_spec.yml +++ b/packages/realm/bindgen/js_opt_in_spec.yml @@ -7,10 +7,10 @@ # * `classes` and their `methods` # * Methods, static methods, constructors, and properties in the general `spec.yml` # should all be listed in this opt-in list as `methods`. -# * `records` and their `fields`` +# * `records` and their `fields` # -# If all methods in a class, or all fields of a property, are opted out of, -# the entire class/property should be removed. +# If all methods in a class, or all fields of a record, are opted out of, +# the entire class/record should be removed. records: Property: @@ -262,7 +262,6 @@ classes: - feed_buffer - make_ssl_verify_callback - needs_file_format_upgrade - - get_mixed_type LogCategoryRef: methods: @@ -333,6 +332,8 @@ classes: - index_of_obj - get_obj - get_any + - get_list + - get_dictionary - sort_by_names - snapshot - max @@ -385,27 +386,35 @@ classes: Collection: methods: - get_object_schema + - get_type - size - is_valid - get_any - as_results + - snapshot List: methods: - make + - get_obj + - get_list + - get_dictionary - move - remove - remove_all - swap - delete_all - insert_any + - insert_collection - insert_embedded - set_any - set_embedded + - set_collection Set: methods: - make + - get_obj - insert_any - remove_any - remove_all @@ -416,10 +425,13 @@ classes: - make - get_keys - get_values + - get_list + - get_dictionary - contains - add_key_based_notification_callback - insert_any - insert_embedded + - insert_collection - try_get_any - remove_all - try_erase diff --git a/packages/realm/bindgen/src/templates/jsi.ts b/packages/realm/bindgen/src/templates/jsi.ts index 0b5e80bdea..639077ac6a 100644 --- a/packages/realm/bindgen/src/templates/jsi.ts +++ b/packages/realm/bindgen/src/templates/jsi.ts @@ -79,7 +79,17 @@ function pushRet<T, U extends T>(arr: T[], elem: U) { class JsiAddon extends CppClass { exports: string[] = []; classes: string[] = []; - injectables = ["Long", "ArrayBuffer", "Float", "UUID", "ObjectId", "Decimal128", "EJSON_parse", "EJSON_stringify"]; + injectables = [ + "Long", + "ArrayBuffer", + "Float", + "UUID", + "ObjectId", + "Decimal128", + "EJSON_parse", + "EJSON_stringify", + "Symbol_for", + ]; mem_inits: CppMemInit[] = []; props = new Set<string>(); @@ -906,6 +916,16 @@ class JsiCppDecls extends CppDecls { `, ) .join("\n")} + + // We are returning sentinel values for lists and dictionaries in the + // form of Symbol singletons. This is due to not being able to construct + // the actual list or dictionary in the current context. + case realm::type_List: + return ${this.addon.accessCtor("Symbol_for")}.call(_env, "Realm.List"); + + case realm::type_Dictionary: + return ${this.addon.accessCtor("Symbol_for")}.call(_env, "Realm.Dictionary"); + // The remaining cases are never stored in a Mixed. ${spec.mixedInfo.unusedDataTypes.map((t) => `case DataType::Type::${t}: break;`).join("\n")} } diff --git a/packages/realm/bindgen/src/templates/node.ts b/packages/realm/bindgen/src/templates/node.ts index f0ae972917..5dc5e90a1a 100644 --- a/packages/realm/bindgen/src/templates/node.ts +++ b/packages/realm/bindgen/src/templates/node.ts @@ -843,6 +843,16 @@ class NodeCppDecls extends CppDecls { `, ) .join("\n")} + + // We are returning sentinel values for lists and dictionaries in the + // form of Symbol singletons. This is due to not being able to construct + // the actual list or dictionary in the current context. + case realm::type_List: + return Napi::Symbol::For(napi_env_var_ForBindGen, "Realm.List"); + + case realm::type_Dictionary: + return Napi::Symbol::For(napi_env_var_ForBindGen, "Realm.Dictionary"); + // The remaining cases are never stored in a Mixed. ${spec.mixedInfo.unusedDataTypes.map((t) => `case DataType::Type::${t}: break;`).join("\n")} } diff --git a/packages/realm/bindgen/src/templates/typescript.ts b/packages/realm/bindgen/src/templates/typescript.ts index 8fa92c2abd..44de3541e4 100644 --- a/packages/realm/bindgen/src/templates/typescript.ts +++ b/packages/realm/bindgen/src/templates/typescript.ts @@ -124,7 +124,7 @@ function generateArguments(spec: BoundSpec, args: Arg[]) { function generateMixedTypes(spec: BoundSpec) { return ` - export type Mixed = null | ${spec.mixedInfo.getters + export type Mixed = null | symbol | ${spec.mixedInfo.getters .map(({ type }) => generateType(spec, type, Kind.Ret)) .join(" | ")}; export type MixedArg = null | ${spec.mixedInfo.ctors.map((type) => generateType(spec, type, Kind.Arg)).join(" | ")}; @@ -173,6 +173,8 @@ export function generate({ rawSpec, spec: boundSpec, file }: TemplateContext): v public reason?: string; constructor(isOk: boolean) { this.isOk = isOk; } } + export const ListSentinel = Symbol.for("Realm.List"); + export const DictionarySentinel = Symbol.for("Realm.Dictionary"); `); const out = file("native.d.ts", eslintFormatter); diff --git a/packages/realm/src/Collection.ts b/packages/realm/src/Collection.ts index 9a6bcd55a6..0bf447370e 100644 --- a/packages/realm/src/Collection.ts +++ b/packages/realm/src/Collection.ts @@ -16,11 +16,37 @@ // //////////////////////////////////////////////////////////////////////////// -import type { Dictionary, List, Results } from "./internal"; +import type { + Dictionary, + DictionaryAccessor, + List, + OrderedCollectionAccessor, + RealmSet, + Results, + TypeHelpers, +} from "./internal"; import { CallbackAdder, IllegalConstructorError, Listeners, TypeAssertionError, assert, binding } from "./internal"; /** - * Abstract base class containing methods shared by Realm {@link List}, {@link Dictionary} and {@link Results}. + * Collection accessor identifier. + * @internal + */ +export const COLLECTION_ACCESSOR = Symbol("Collection#accessor"); + +/** + * Collection type helpers identifier. + * @internal + */ +export const COLLECTION_TYPE_HELPERS = Symbol("Collection#typeHelpers"); + +/** + * Accessor for getting and setting items in the binding collection. + * @internal + */ +type CollectionAccessor<T = unknown> = OrderedCollectionAccessor<T> | DictionaryAccessor<T>; + +/** + * Abstract base class containing methods shared by Realm {@link List}, {@link Dictionary}, {@link Results} and {@link RealmSet}. * * A {@link Collection} always reflect the current state of the Realm. The one exception to this is * when using `for...in` or `for...of` enumeration, which will always enumerate over the @@ -34,13 +60,31 @@ export abstract class Collection< EntryType = [KeyType, ValueType], T = ValueType, ChangeCallbackType = unknown, + /** @internal */ + Accessor extends CollectionAccessor<ValueType> = CollectionAccessor<ValueType>, > implements Iterable<T> { + /** + * Accessor for getting and setting items in the binding collection. + * @internal + */ + protected readonly [COLLECTION_ACCESSOR]: Accessor; + + /** + * Helper for converting the values to and from their binding representations. + * @internal + */ + protected readonly [COLLECTION_TYPE_HELPERS]: TypeHelpers<ValueType>; + /** @internal */ private listeners: Listeners<ChangeCallbackType, binding.NotificationToken, [string[] | undefined]>; /** @internal */ - constructor(addListener: CallbackAdder<ChangeCallbackType, binding.NotificationToken, [string[] | undefined]>) { + constructor( + accessor: Accessor, + typeHelpers: TypeHelpers<ValueType>, + addListener: CallbackAdder<ChangeCallbackType, binding.NotificationToken, [string[] | undefined]>, + ) { if (arguments.length === 0) { throw new IllegalConstructorError("Collection"); } @@ -56,6 +100,9 @@ export abstract class Collection< configurable: false, writable: false, }); + + this[COLLECTION_ACCESSOR] = accessor; + this[COLLECTION_TYPE_HELPERS] = typeHelpers; } /** diff --git a/packages/realm/src/Dictionary.ts b/packages/realm/src/Dictionary.ts index 649ef75192..9cc0eb181d 100644 --- a/packages/realm/src/Dictionary.ts +++ b/packages/realm/src/Dictionary.ts @@ -15,49 +15,55 @@ // limitations under the License. // //////////////////////////////////////////////////////////////////////////// + import { + COLLECTION_ACCESSOR as ACCESSOR, AssertionError, Collection, DefaultObject, IllegalConstructorError, JSONCacheMap, + List, Realm, RealmObject, + Results, + COLLECTION_TYPE_HELPERS as TYPE_HELPERS, TypeHelpers, assert, binding, + createListAccessor, + createResultsAccessor, + insertIntoListOfMixed, + isJsOrRealmList, + toItemType, } from "./internal"; /* eslint-disable jsdoc/multiline-blocks -- We need this to have @ts-expect-error located correctly in the .d.ts bundle */ const REALM = Symbol("Dictionary#realm"); const INTERNAL = Symbol("Dictionary#internal"); -const HELPERS = Symbol("Dictionary#helpers"); export type DictionaryChangeSet = { deletions: string[]; modifications: string[]; insertions: string[]; }; -export type DictionaryChangeCallback = (dictionary: Dictionary, changes: DictionaryChangeSet) => void; + +export type DictionaryChangeCallback<T = unknown> = (dictionary: Dictionary<T>, changes: DictionaryChangeSet) => void; const DEFAULT_PROPERTY_DESCRIPTOR: PropertyDescriptor = { configurable: true, enumerable: true }; const PROXY_HANDLER: ProxyHandler<Dictionary> = { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === "undefined" && typeof prop === "string") { - const internal = target[INTERNAL]; - const fromBinding = target[HELPERS].fromBinding; - return fromBinding(internal.tryGetAny(prop)); + return target[ACCESSOR].get(target[INTERNAL], prop); } else { return value; } }, set(target, prop, value) { if (typeof prop === "string") { - const internal = target[INTERNAL]; - const toBinding = target[HELPERS].toBinding; - internal.insertAny(prop, toBinding(value)); + target[ACCESSOR].set(target[INTERNAL], prop, value); return true; } else { assert(typeof prop !== "symbol", "Symbols cannot be used as keys of a dictionary"); @@ -106,16 +112,38 @@ const PROXY_HANDLER: ProxyHandler<Dictionary> = { * Dictionaries behave mostly like a JavaScript object i.e., as a key/value pair * where the key is a string. */ -export class Dictionary<T = unknown> extends Collection<string, T, [string, T], [string, T], DictionaryChangeCallback> { +export class Dictionary<T = unknown> extends Collection< + string, + T, + [string, T], + [string, T], + DictionaryChangeCallback<T>, + /** @internal */ + DictionaryAccessor<T> +> { + /** @internal */ + private declare [REALM]: Realm; + + /** + * The representation in the binding. + * @internal + */ + private readonly [INTERNAL]: binding.Dictionary; + /** * Create a `Results` wrapping a set of query `Results` from the binding. * @internal */ - constructor(realm: Realm, internal: binding.Dictionary, helpers: TypeHelpers) { + constructor( + realm: Realm, + internal: binding.Dictionary, + accessor: DictionaryAccessor<T>, + typeHelpers: TypeHelpers<T>, + ) { if (arguments.length === 0 || !(internal instanceof binding.Dictionary)) { throw new IllegalConstructorError("Dictionary"); } - super((listener, keyPaths) => { + super(accessor, typeHelpers, (listener, keyPaths) => { return this[INTERNAL].addKeyBasedNotificationCallback( ({ deletions, insertions, modifications }) => { try { @@ -145,7 +173,7 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], ); }); - const proxied = new Proxy(this, PROXY_HANDLER) as Dictionary<T>; + const proxied = new Proxy(this, PROXY_HANDLER as ProxyHandler<this>); Object.defineProperty(this, REALM, { enumerable: false, @@ -153,37 +181,12 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], writable: false, value: realm, }); - Object.defineProperty(this, INTERNAL, { - enumerable: false, - configurable: false, - writable: false, - value: internal, - }); - Object.defineProperty(this, HELPERS, { - enumerable: false, - configurable: false, - writable: false, - value: helpers, - }); + + this[INTERNAL] = internal; return proxied; } - /** - * The representation in the binding. - * @internal - */ - private declare [REALM]: Realm; - - /** - * The representation in the binding. - * @internal - */ - private declare [INTERNAL]: binding.Dictionary; - - /** @internal */ - private declare [HELPERS]: TypeHelpers; - /** @ts-expect-error We're exposing methods in the end-users namespace of keys */ [key: string]: T; @@ -216,12 +219,15 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], * @since 10.5.0 * @ts-expect-error We're exposing methods in the end-users namespace of values */ *values(): Generator<T> { - const { fromBinding } = this[HELPERS]; - const snapshot = this[INTERNAL].values.snapshot(); - const size = snapshot.size(); - for (let i = 0; i < size; i++) { - const value = snapshot.getAny(i); - yield fromBinding(value) as T; + const realm = this[REALM]; + const values = this[INTERNAL].values; + const itemType = toItemType(values.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + const results = new Results<T>(realm, values, accessor, typeHelpers); + + for (const value of results.values()) { + yield value; } } @@ -231,15 +237,21 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], * @since 10.5.0 * @ts-expect-error We're exposing methods in the end-users namespace of entries */ *entries(): Generator<[string, T]> { - const { fromBinding } = this[HELPERS]; const keys = this[INTERNAL].keys.snapshot(); - const values = this[INTERNAL].values.snapshot(); + const snapshot = this[INTERNAL].values.snapshot(); const size = keys.size(); - assert(size === values.size(), "Expected keys and values to equal in size"); + assert(size === snapshot.size(), "Expected keys and values to equal in size"); + + const realm = this[REALM]; + const itemType = toItemType(snapshot.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + const results = new Results<T>(realm, snapshot, accessor, typeHelpers); + for (let i = 0; i < size; i++) { const key = keys.getAny(i); - const value = values.getAny(i); - yield [key, fromBinding(value)] as [string, T]; + const value = results[i]; + yield [key, value] as [string, T]; } } @@ -278,14 +290,12 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], * @since 10.6.0 */ set(elementsOrKey: string | { [key: string]: T }, value?: T): this { - const elements = typeof elementsOrKey === "object" ? elementsOrKey : { [elementsOrKey]: value }; - assert(Object.getOwnPropertySymbols(elements).length === 0, "Symbols cannot be used as keys of a dictionary"); assert.inTransaction(this[REALM]); - const internal = this[INTERNAL]; - const toBinding = this[HELPERS].toBinding; + const elements = typeof elementsOrKey === "object" ? elementsOrKey : { [elementsOrKey]: value as T }; + assert(Object.getOwnPropertySymbols(elements).length === 0, "Symbols cannot be used as keys of a dictionary"); - for (const [key, val] of Object.entries(elements)) { - internal.insertAny(key, toBinding(val)); + for (const [key, value] of Object.entries(elements)) { + this[key] = value; } return this; } @@ -322,3 +332,124 @@ export class Dictionary<T = unknown> extends Collection<string, T, [string, T], ); } } + +/** + * Accessor for getting and setting items in the binding collection. + * @internal + */ +export type DictionaryAccessor<T = unknown> = { + get: (dictionary: binding.Dictionary, key: string) => T; + set: (dictionary: binding.Dictionary, key: string, value: T) => void; +}; + +type DictionaryAccessorFactoryOptions<T> = { + realm: Realm; + typeHelpers: TypeHelpers<T>; + itemType: binding.PropertyType; + isEmbedded?: boolean; +}; + +/** @internal */ +export function createDictionaryAccessor<T>(options: DictionaryAccessorFactoryOptions<T>): DictionaryAccessor<T> { + return options.itemType === binding.PropertyType.Mixed + ? createDictionaryAccessorForMixed<T>(options) + : createDictionaryAccessorForKnownType<T>(options); +} + +function createDictionaryAccessorForMixed<T>({ + realm, + typeHelpers, +}: Pick<DictionaryAccessorFactoryOptions<T>, "realm" | "typeHelpers">): DictionaryAccessor<T> { + const { toBinding, fromBinding } = typeHelpers; + return { + get(dictionary, key) { + const value = dictionary.tryGetAny(key); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor<T>({ realm, itemType: binding.PropertyType.Mixed, typeHelpers }); + return new List<T>(realm, dictionary.getList(key), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor<T>({ realm, itemType: binding.PropertyType.Mixed, typeHelpers }); + return new Dictionary<T>(realm, dictionary.getDictionary(key), accessor, typeHelpers) as T; + } + default: + return fromBinding(value) as T; + } + }, + set(dictionary, key, value) { + assert.inTransaction(realm); + + if (isJsOrRealmList(value)) { + dictionary.insertCollection(key, binding.CollectionType.List); + insertIntoListOfMixed(value, dictionary.getList(key), toBinding); + } else if (isJsOrRealmDictionary(value)) { + dictionary.insertCollection(key, binding.CollectionType.Dictionary); + insertIntoDictionaryOfMixed(value, dictionary.getDictionary(key), toBinding); + } else { + dictionary.insertAny(key, toBinding(value)); + } + }, + }; +} + +function createDictionaryAccessorForKnownType<T>({ + realm, + typeHelpers, + isEmbedded, +}: Omit<DictionaryAccessorFactoryOptions<T>, "itemType">): DictionaryAccessor<T> { + const { fromBinding, toBinding } = typeHelpers; + return { + get(dictionary, key) { + return fromBinding(dictionary.tryGetAny(key)); + }, + set(dictionary, key, value) { + assert.inTransaction(realm); + + if (isEmbedded) { + toBinding(value, { createObj: () => [dictionary.insertEmbedded(key), true] }); + } else { + dictionary.insertAny(key, toBinding(value)); + } + }, + }; +} + +/** @internal */ +export function insertIntoDictionaryOfMixed( + dictionary: Dictionary | Record<string, unknown>, + internal: binding.Dictionary, + toBinding: TypeHelpers["toBinding"], +) { + // TODO: Solve the "removeAll()" case for self-assignment. + internal.removeAll(); + + for (const key in dictionary) { + const value = dictionary[key]; + if (isJsOrRealmList(value)) { + internal.insertCollection(key, binding.CollectionType.List); + insertIntoListOfMixed(value, internal.getList(key), toBinding); + } else if (isJsOrRealmDictionary(value)) { + internal.insertCollection(key, binding.CollectionType.Dictionary); + insertIntoDictionaryOfMixed(value, internal.getDictionary(key), toBinding); + } else { + internal.insertAny(key, toBinding(value)); + } + } +} + +/** @internal */ +export function isJsOrRealmDictionary(value: unknown): value is Dictionary | Record<string, unknown> { + return isPOJO(value) || value instanceof Dictionary; +} + +/** @internal */ +export function isPOJO(value: unknown): value is Record<string, unknown> { + return ( + typeof value === "object" && + value !== null && + // Lastly check for the absence of a prototype as POJOs + // can still be created using `Object.create(null)`. + (value.constructor === Object || !Object.getPrototypeOf(value)) + ); +} diff --git a/packages/realm/src/GeoSpatial.ts b/packages/realm/src/GeoSpatial.ts index 23441bafc9..4a4dc39340 100644 --- a/packages/realm/src/GeoSpatial.ts +++ b/packages/realm/src/GeoSpatial.ts @@ -123,6 +123,24 @@ export type GeoBox = { topRight: GeoPoint; }; +/** @internal */ +export function isGeoCircle(value: object): value is GeoCircle { + return "center" in value && "distance" in value && typeof value.distance === "number"; +} + +/** @internal */ +export function isGeoBox(value: object): value is GeoBox { + return "bottomLeft" in value && "topRight" in value; +} + +/** @internal */ +export function isGeoPolygon(value: object): value is GeoPolygon { + return ( + ("type" in value && value.type === "Polygon" && "coordinates" in value && Array.isArray(value.coordinates)) || + ("outerRing" in value && Array.isArray(value.outerRing)) + ); +} + /** @internal */ export function circleToBindingGeospatial(circle: GeoCircle): binding.Geospatial { return binding.Geospatial.makeFromCircle({ diff --git a/packages/realm/src/List.ts b/packages/realm/src/List.ts index d37f3b7b36..2e4564e8b8 100644 --- a/packages/realm/src/List.ts +++ b/packages/realm/src/List.ts @@ -17,14 +17,21 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, AssertionError, + Dictionary, IllegalConstructorError, ObjectSchema, OrderedCollection, - OrderedCollectionHelpers, Realm, + TypeHelpers, assert, binding, + createDefaultGetter, + createDictionaryAccessor, + insertIntoDictionaryOfMixed, + isJsOrRealmDictionary, + toItemType, } from "./internal"; type PartiallyWriteableArray<T> = Pick<Array<T>, "pop" | "push" | "shift" | "unshift" | "splice">; @@ -36,27 +43,36 @@ type PartiallyWriteableArray<T> = Pick<Array<T>, "pop" | "push" | "shift" | "uns * only store values of a single type (indicated by the `type` and `optional` * properties of the List), and can only be modified inside a {@link Realm.write | write} transaction. */ -export class List<T = unknown> extends OrderedCollection<T> implements PartiallyWriteableArray<T> { +export class List<T = unknown> + extends OrderedCollection< + T, + [number, T], + /** @internal */ + ListAccessor<T> + > + implements PartiallyWriteableArray<T> +{ /** * The representation in the binding. * @internal */ - public declare internal: binding.List; + public declare readonly internal: binding.List; /** @internal */ private declare isEmbedded: boolean; /** @internal */ - constructor(realm: Realm, internal: binding.List, helpers: OrderedCollectionHelpers) { + constructor(realm: Realm, internal: binding.List, accessor: ListAccessor<T>, typeHelpers: TypeHelpers<T>) { if (arguments.length === 0 || !(internal instanceof binding.List)) { throw new IllegalConstructorError("List"); } - super(realm, internal.asResults(), helpers); + const results = internal.asResults(); + super(realm, results, accessor, typeHelpers); // Getting the `objectSchema` off the internal will throw if base type isn't object - const baseType = this.results.type & ~binding.PropertyType.Flags; const isEmbedded = - baseType === binding.PropertyType.Object && internal.objectSchema.tableType === binding.TableType.Embedded; + toItemType(results.type) === binding.PropertyType.Object && + internal.objectSchema.tableType === binding.TableType.Embedded; Object.defineProperty(this, "internal", { enumerable: false, @@ -72,6 +88,16 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially }); } + /** @internal */ + public get(index: number): T { + return this[ACCESSOR].get(this.internal, index); + } + + /** @internal */ + public set(index: number, value: T): void { + this[ACCESSOR].set(this.internal, index, value); + } + /** * Checks if this collection has not been deleted and is part of a valid Realm. * @returns `true` if the collection can be safely accessed. @@ -80,27 +106,6 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially return this.internal.isValid; } - /** - * Set an element of the ordered collection by index - * @param index The index - * @param value The value - * @internal - */ - public set(index: number, value: unknown): void { - const { - realm, - internal, - isEmbedded, - helpers: { toBinding }, - } = this; - assert.inTransaction(realm); - // TODO: Consider a more performant way to determine if the list is embedded - internal.setAny( - index, - toBinding(value, isEmbedded ? { createObj: () => [internal.setEmbedded(index), true] } : undefined), - ); - } - /** * @returns The number of values in the list. */ @@ -122,15 +127,12 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially */ pop(): T | undefined { assert.inTransaction(this.realm); - const { - internal, - helpers: { fromBinding }, - } = this; + const { internal } = this; const lastIndex = internal.size - 1; if (lastIndex >= 0) { - const result = fromBinding(internal.getAny(lastIndex)); + const result = this.get(lastIndex); internal.remove(lastIndex); - return result as T; + return result; } } @@ -144,20 +146,11 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially */ push(...items: T[]): number { assert.inTransaction(this.realm); - const { - isEmbedded, - internal, - helpers: { toBinding }, - } = this; + const { internal } = this; const start = internal.size; for (const [offset, item] of items.entries()) { const index = start + offset; - if (isEmbedded) { - // Simply transforming to binding will insert the embedded object - toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); - } else { - internal.insertAny(index, toBinding(item)); - } + this[ACCESSOR].insert(internal, index, item); } return internal.size; } @@ -169,12 +162,9 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially */ shift(): T | undefined { assert.inTransaction(this.realm); - const { - internal, - helpers: { fromBinding }, - } = this; + const { internal } = this; if (internal.size > 0) { - const result = fromBinding(internal.getAny(0)) as T; + const result = this.get(0); internal.remove(0); return result; } @@ -190,18 +180,10 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially */ unshift(...items: T[]): number { assert.inTransaction(this.realm); - const { - isEmbedded, - internal, - helpers: { toBinding }, - } = this; + const { internal } = this; + const { insert } = this[ACCESSOR]; for (const [index, item] of items.entries()) { - if (isEmbedded) { - // Simply transforming to binding will insert the embedded object - toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); - } else { - internal.insertAny(index, toBinding(item)); - } + insert(internal, index, item); } return internal.size; } @@ -250,11 +232,7 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially // Comments in the code below is copied from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice assert.inTransaction(this.realm); assert.number(start, "start"); - const { - isEmbedded, - internal, - helpers: { fromBinding, toBinding }, - } = this; + const { internal } = this; // If negative, it will begin that many elements from the end of the array. if (start < 0) { start = internal.size + start; @@ -270,21 +248,17 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially // Get the elements that are about to be deleted const result: T[] = []; for (let i = start; i < end; i++) { - result.push(fromBinding(internal.getAny(i)) as T); + result.push(this.get(i)); } // Remove the elements from the list (backwards to avoid skipping elements as they're being deleted) for (let i = end - 1; i >= start; i--) { internal.remove(i); } // Insert any new elements + const { insert } = this[ACCESSOR]; for (const [offset, item] of items.entries()) { const index = start + offset; - if (isEmbedded) { - // Simply transforming to binding will insert the embedded object - toBinding(item, { createObj: () => [internal.insertEmbedded(index), true] }); - } else { - internal.insertAny(index, toBinding(item)); - } + insert(internal, index, item); } return result; } @@ -343,3 +317,132 @@ export class List<T = unknown> extends OrderedCollection<T> implements Partially this.internal.swap(index1, index2); } } + +/** + * Accessor for getting, setting, and inserting items in the binding collection. + * @internal + */ +export type ListAccessor<T = unknown> = { + get: (list: binding.List, index: number) => T; + set: (list: binding.List, index: number, value: T) => void; + insert: (list: binding.List, index: number, value: T) => void; +}; + +type ListAccessorFactoryOptions<T> = { + realm: Realm; + typeHelpers: TypeHelpers<T>; + itemType: binding.PropertyType; + isEmbedded?: boolean; +}; + +/** @internal */ +export function createListAccessor<T>(options: ListAccessorFactoryOptions<T>): ListAccessor<T> { + return options.itemType === binding.PropertyType.Mixed + ? createListAccessorForMixed<T>(options) + : createListAccessorForKnownType<T>(options); +} + +function createListAccessorForMixed<T>({ + realm, + typeHelpers, +}: Pick<ListAccessorFactoryOptions<T>, "realm" | "typeHelpers">): ListAccessor<T> { + const { toBinding } = typeHelpers; + return { + get(list, index) { + const value = list.getAny(index); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor<T>({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new List<T>(realm, list.getList(index), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor<T>({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new Dictionary<T>(realm, list.getDictionary(index), accessor, typeHelpers) as T; + } + default: + return typeHelpers.fromBinding(value); + } + }, + set(list, index, value) { + assert.inTransaction(realm); + + if (isJsOrRealmList(value)) { + list.setCollection(index, binding.CollectionType.List); + insertIntoListOfMixed(value, list.getList(index), toBinding); + } else if (isJsOrRealmDictionary(value)) { + list.setCollection(index, binding.CollectionType.Dictionary); + insertIntoDictionaryOfMixed(value, list.getDictionary(index), toBinding); + } else { + list.setAny(index, toBinding(value)); + } + }, + insert(list, index, value) { + assert.inTransaction(realm); + + if (isJsOrRealmList(value)) { + list.insertCollection(index, binding.CollectionType.List); + insertIntoListOfMixed(value, list.getList(index), toBinding); + } else if (isJsOrRealmDictionary(value)) { + list.insertCollection(index, binding.CollectionType.Dictionary); + insertIntoDictionaryOfMixed(value, list.getDictionary(index), toBinding); + } else { + list.insertAny(index, toBinding(value)); + } + }, + }; +} + +function createListAccessorForKnownType<T>({ + realm, + typeHelpers, + itemType, + isEmbedded, +}: Omit<ListAccessorFactoryOptions<T>, "isMixed">): ListAccessor<T> { + const { fromBinding, toBinding } = typeHelpers; + return { + get: createDefaultGetter({ fromBinding, itemType }), + set(list, index, value) { + assert.inTransaction(realm); + list.setAny( + index, + toBinding(value, isEmbedded ? { createObj: () => [list.setEmbedded(index), true] } : undefined), + ); + }, + insert(list, index, value) { + assert.inTransaction(realm); + if (isEmbedded) { + // Simply transforming to binding will insert the embedded object + toBinding(value, { createObj: () => [list.insertEmbedded(index), true] }); + } else { + list.insertAny(index, toBinding(value)); + } + }, + }; +} + +/** @internal */ +export function insertIntoListOfMixed( + list: List | unknown[], + internal: binding.List, + toBinding: TypeHelpers["toBinding"], +) { + // TODO: Solve the "removeAll()" case for self-assignment. + internal.removeAll(); + + for (const [index, item] of list.entries()) { + if (isJsOrRealmList(item)) { + internal.insertCollection(index, binding.CollectionType.List); + insertIntoListOfMixed(item, internal.getList(index), toBinding); + } else if (isJsOrRealmDictionary(item)) { + internal.insertCollection(index, binding.CollectionType.Dictionary); + insertIntoDictionaryOfMixed(item, internal.getDictionary(index), toBinding); + } else { + internal.insertAny(index, toBinding(item)); + } + } +} + +/** @internal */ +export function isJsOrRealmList(value: unknown): value is List | unknown[] { + return Array.isArray(value) || value instanceof List; +} diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index 51135039f5..b2d9e0b0ea 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -29,14 +29,15 @@ import { ObjectListeners, OmittedRealmTypes, OrderedCollection, - OrderedCollectionHelpers, Realm, RealmObjectConstructor, Results, TypeAssertionError, + TypeHelpers, Unmanaged, assert, binding, + createResultsAccessor, flags, getTypeName, } from "./internal"; @@ -429,7 +430,8 @@ export class RealmObject<T = DefaultObject, RequiredProperties extends keyof Omi linkingObjects<T = DefaultObject>(objectType: string, propertyName: string): Results<RealmObject<T> & T>; linkingObjects<T extends AnyRealmObject>(objectType: Constructor<T>, propertyName: string): Results<T>; linkingObjects<T extends AnyRealmObject>(objectType: string | Constructor<T>, propertyName: string): Results<T> { - const targetClassHelpers = this[REALM].getClassHelpers(objectType); + const realm = this[REALM]; + const targetClassHelpers = realm.getClassHelpers(objectType); const { objectSchema: targetObjectSchema, properties, wrapObject } = targetClassHelpers; const targetProperty = properties.get(propertyName); const originObjectSchema = this.objectSchema(); @@ -439,27 +441,24 @@ export class RealmObject<T = DefaultObject, RequiredProperties extends keyof Omi () => `'${targetObjectSchema.name}#${propertyName}' is not a relationship to '${originObjectSchema.name}'`, ); - const collectionHelpers: OrderedCollectionHelpers = { + const typeHelpers: TypeHelpers<T> = { // See `[binding.PropertyType.LinkingObjects]` in `TypeHelpers.ts`. toBinding(value: unknown) { return value as binding.MixedArg; }, fromBinding(value: unknown) { assert.instanceOf(value, binding.Obj); - return wrapObject(value); - }, - // See `[binding.PropertyType.Array]` in `PropertyHelpers.ts`. - get(results: binding.Results, index: number) { - return results.getObj(index); + return wrapObject(value) as T; }, }; + const accessor = createResultsAccessor<T>({ realm, typeHelpers, itemType: binding.PropertyType.Object }); // Create the Result for the backlink view. - const tableRef = binding.Helpers.getTable(this[REALM].internal, targetObjectSchema.tableKey); + const tableRef = binding.Helpers.getTable(realm.internal, targetObjectSchema.tableKey); const tableView = this[INTERNAL].getBacklinkView(tableRef, targetProperty.columnKey); - const results = binding.Results.fromTableView(this[REALM].internal, tableView); + const results = binding.Results.fromTableView(realm.internal, tableView); - return new Results(this[REALM], results, collectionHelpers); + return new Results<T>(realm, results, accessor, typeHelpers); } /** @@ -574,6 +573,12 @@ export class RealmObject<T = DefaultObject, RequiredProperties extends keyof Omi return "decimal128"; } else if (value instanceof BSON.UUID) { return "uuid"; + } else if (value === binding.ListSentinel) { + return "list"; + } else if (value === binding.DictionarySentinel) { + return "dictionary"; + } else if (typeof value === "symbol") { + throw new Error(`Unexpected Symbol: ${value.toString()}`); } else { assert.never(value, "value"); } diff --git a/packages/realm/src/OrderedCollection.ts b/packages/realm/src/OrderedCollection.ts index 2c825fdce5..59b0407620 100644 --- a/packages/realm/src/OrderedCollection.ts +++ b/packages/realm/src/OrderedCollection.ts @@ -20,25 +20,41 @@ import { ClassHelpers, Collection, DefaultObject, - INTERNAL, IllegalConstructorError, JSONCacheMap, + ListAccessor, + INTERNAL as OBJ_INTERNAL, Realm, RealmObject, Results, + ResultsAccessor, + SetAccessor, + COLLECTION_TYPE_HELPERS as TYPE_HELPERS, TypeAssertionError, TypeHelpers, assert, binding, + createResultsAccessor, getTypeName, + isJsOrRealmDictionary, + isJsOrRealmList, mixedToBinding, + toItemType, unwind, } from "./internal"; const DEFAULT_COLUMN_KEY = binding.Int64.numToInt(0) as unknown as binding.ColKey; +type OrderedCollectionInternal = binding.List | binding.Results | binding.Set; type PropertyType = string; +/** + * Accessor for getting and setting items in the binding collection, as + * well as converting the values to and from their binding representations. + * @internal + */ +export type OrderedCollectionAccessor<T = unknown> = ListAccessor<T> | ResultsAccessor<T> | SetAccessor<T>; + /** * A sort descriptor is either a string containing one or more property names * separate by dots or an array with two items: `[propertyName, reverse]`. @@ -75,11 +91,6 @@ export type CollectionChangeCallback<T = unknown, EntryType extends [unknown, un changes: CollectionChangeSet, ) => void; -/** @internal */ -export type OrderedCollectionHelpers = TypeHelpers & { - get(results: binding.Results, index: number): unknown; -}; - const DEFAULT_PROPERTY_DESCRIPTOR: PropertyDescriptor = { configurable: true, enumerable: true, writable: true }; const PROXY_HANDLER: ProxyHandler<OrderedCollection> = { // TODO: Consider executing the `parseInt` first to optimize for index access over accessing a member on the list @@ -97,13 +108,19 @@ const PROXY_HANDLER: ProxyHandler<OrderedCollection> = { set(target, prop, value, receiver) { if (typeof prop === "string") { const index = Number.parseInt(prop, 10); - // TODO: Consider catching an error from access out of bounds, instead of checking the length, to optimize for the hot path - // TODO: Do we expect an upper bound check on the index when setting? if (Number.isInteger(index)) { - if (index < 0) { - throw new Error(`Index ${index} cannot be less than zero.`); + // Optimize for the hot-path by catching a potential out of bounds access from Core, rather + // than checking the length upfront. Thus, our List differs from the behavior of a JS array. + try { + target.set(index, value); + } catch (err) { + // Let the custom errors from Results take precedence over out of bounds errors. This will + // let users know that they cannot modify Results, rather than erroring on incorrect index. + if (index < 0 && !(target instanceof Results)) { + throw new Error(`Cannot set item at negative index ${index}.`); + } + throw err; } - target.set(index, value); return true; } } @@ -131,19 +148,39 @@ const PROXY_HANDLER: ProxyHandler<OrderedCollection> = { * subscripting, enumerating with `for-of` and so on. * @see {@link https://mdn.io/Array | Array} */ -export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, unknown] = [number, T]> - extends Collection<number, T, EntryType, T, CollectionChangeCallback<T, EntryType>> +export abstract class OrderedCollection< + T = unknown, + EntryType extends [unknown, unknown] = [number, T], + /** @internal */ + Accessor extends OrderedCollectionAccessor<T> = OrderedCollectionAccessor<T>, + > + extends Collection< + number, + T, + EntryType, + T, + CollectionChangeCallback<T, EntryType>, + /** @internal */ + Accessor + > implements Omit<ReadonlyArray<T>, "entries"> { /** @internal */ protected declare realm: Realm; + + /** + * The representation in the binding of the underlying collection. + * @internal + */ + public abstract readonly internal: OrderedCollectionInternal; + /** @internal */ protected declare results: binding.Results; - /** @internal */ protected declare helpers: OrderedCollectionHelpers; + /** @internal */ - constructor(realm: Realm, results: binding.Results, helpers: OrderedCollectionHelpers) { + constructor(realm: Realm, results: binding.Results, accessor: Accessor, typeHelpers: TypeHelpers<T>) { if (arguments.length === 0) { throw new IllegalConstructorError("OrderedCollection"); } - super((callback, keyPaths) => { + super(accessor, typeHelpers, (callback, keyPaths) => { return results.addNotificationCallback( (changes) => { try { @@ -166,6 +203,7 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, }); // Wrap in a proxy to trap ownKeys and get, enabling the spread operator const proxied = new Proxy(this, PROXY_HANDLER as ProxyHandler<this>); + // Get the class helpers for later use, if available const { objectType } = results; const classHelpers = typeof objectType === "string" && objectType !== "" ? realm.getClassHelpers(objectType) : null; @@ -182,12 +220,6 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, writable: false, value: results, }); - Object.defineProperty(this, "helpers", { - enumerable: false, - configurable: false, - writable: false, - value: helpers, - }); Object.defineProperty(this, "classHelpers", { enumerable: false, configurable: false, @@ -206,6 +238,7 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, configurable: true, writable: false, }); + return proxied; } @@ -215,25 +248,16 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, private declare mixedToBinding: (value: unknown, options: { isQueryArg: boolean }) => binding.MixedArg; /** - * Get an element of the ordered collection by index. - * @param index - The index. - * @returns The element. + * Get an element of the collection. * @internal */ - public get(index: number): T { - return this.helpers.fromBinding(this.helpers.get(this.results, index)) as T; - } + public abstract get(index: number): T; /** - * Set an element of the ordered collection by index. - * @param index - The index. - * @param value - The value. + * Set an element in the collection. * @internal */ - public set(index: number, value: T): void; - public set() { - throw new Error(`Assigning into a ${this.constructor.name} is not supported`); - } + public abstract set(index: number, value: T): void; /** * The plain object representation for JSON serialization. @@ -269,10 +293,9 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, * @returns An iterator with all values in the collection. */ *values(): Generator<T> { - const snapshot = this.results.snapshot(); - const { get, fromBinding } = this.helpers; + const snapshot = this.snapshot(); for (const i of this.keys()) { - yield fromBinding(get(snapshot, i)) as T; + yield snapshot[i]; } } @@ -281,11 +304,10 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, * @returns An iterator with all key/value pairs in the collection. */ *entries(): Generator<EntryType> { - const { get, fromBinding } = this.helpers; - const snapshot = this.results.snapshot(); - const size = snapshot.size(); + const snapshot = this.snapshot(); + const size = snapshot.length; for (let i = 0; i < size; i++) { - yield [i, fromBinding(get(snapshot, i))] as EntryType; + yield [i, snapshot[i]] as EntryType; } } @@ -310,7 +332,7 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, * @returns The name of the type of values. */ get type(): PropertyType { - return getTypeName(this.results.type & ~binding.PropertyType.Flags, undefined); + return getTypeName(toItemType(this.results.type), undefined); } /** @@ -379,11 +401,17 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, */ indexOf(searchElement: T, fromIndex?: number): number { assert(typeof fromIndex === "undefined", "The second fromIndex argument is not yet supported"); + if (this.type === "object") { assert.instanceOf(searchElement, RealmObject); - return this.results.indexOfObj(searchElement[INTERNAL]); + return this.results.indexOfObj(searchElement[OBJ_INTERNAL]); + } else if (isJsOrRealmList(searchElement) || isJsOrRealmDictionary(searchElement)) { + // Collections are always treated as not equal since their + // references will always be different for each access. + const NOT_FOUND = -1; + return NOT_FOUND; } else { - return this.results.indexOf(this.helpers.toBinding(searchElement)); + return this.results.indexOf(this[TYPE_HELPERS].toBinding(searchElement)); } } /** @@ -795,12 +823,16 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, * let merlots = wines.filtered('variety == "Merlot" && vintage <= $0', maxYear); */ filtered(queryString: string, ...args: unknown[]): Results<T> { - const { results: parent, realm, helpers } = this; + const { results: parent, realm } = this; const kpMapping = binding.Helpers.getKeypathMapping(realm.internal); const bindingArgs = args.map((arg) => this.queryArgToBinding(arg)); const newQuery = parent.query.table.query(queryString, bindingArgs, kpMapping); const results = binding.Helpers.resultsAppendQuery(parent, newQuery); - return new Results(realm, results, helpers); + + const itemType = toItemType(results.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + return new Results(realm, results, accessor, typeHelpers); } /** @internal */ @@ -871,7 +903,7 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, sorted(arg0: boolean | SortDescriptor[] | string = "self", arg1?: boolean): Results<T> { if (Array.isArray(arg0)) { assert.undefined(arg1, "second 'argument'"); - const { results: parent, realm, helpers } = this; + const { results: parent, realm } = this; // Map optional "reversed" to "ascending" (expected by the binding) const descriptors = arg0.map<[string, boolean]>((arg, i) => { if (typeof arg === "string") { @@ -887,7 +919,10 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, }); // TODO: Call `parent.sort`, avoiding property name to column key conversion to speed up performance here. const results = parent.sortByNames(descriptors); - return new Results(realm, results, helpers); + const itemType = toItemType(results.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + return new Results(realm, results, accessor, typeHelpers); } else if (typeof arg0 === "string") { return this.sorted([[arg0, arg1 === true]]); } else if (typeof arg0 === "boolean") { @@ -912,7 +947,12 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, * @returns Results which will **not** live update. */ snapshot(): Results<T> { - return new Results(this.realm, this.results.snapshot(), this.helpers); + const { realm, internal } = this; + const snapshot = internal.snapshot(); + const itemType = toItemType(snapshot.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + return new Results(realm, snapshot, accessor, typeHelpers); } /** @internal */ @@ -932,3 +972,35 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown, return this.realm.internal.createKeyPathArray(this.results.objectType, keyPaths); } } + +type Getter<CollectionType, T> = (collection: CollectionType, index: number) => T; + +type GetterFactoryOptions<T> = { + fromBinding: TypeHelpers<T>["fromBinding"]; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createDefaultGetter<CollectionType extends OrderedCollectionInternal, T>({ + fromBinding, + itemType, +}: GetterFactoryOptions<T>): Getter<CollectionType, T> { + const isObjectItem = itemType === binding.PropertyType.Object || itemType === binding.PropertyType.LinkingObjects; + return isObjectItem ? (...args) => getObject(fromBinding, ...args) : (...args) => getKnownType(fromBinding, ...args); +} + +function getObject<T>( + fromBinding: TypeHelpers<T>["fromBinding"], + collection: OrderedCollectionInternal, + index: number, +): T { + return fromBinding(collection.getObj(index)); +} + +function getKnownType<T>( + fromBinding: TypeHelpers<T>["fromBinding"], + collection: OrderedCollectionInternal, + index: number, +): T { + return fromBinding(collection.getAny(index)); +} diff --git a/packages/realm/src/PropertyHelpers.ts b/packages/realm/src/PropertyHelpers.ts index f60976c892..0db8ac1aa9 100644 --- a/packages/realm/src/PropertyHelpers.ts +++ b/packages/realm/src/PropertyHelpers.ts @@ -20,7 +20,7 @@ import { ClassHelpers, Dictionary, List, - OrderedCollectionHelpers, + ListAccessor, Realm, RealmSet, Results, @@ -29,7 +29,16 @@ import { TypeOptions, assert, binding, + createDictionaryAccessor, + createListAccessor, + createResultsAccessor, + createSetAccessor, getTypeHelpers, + insertIntoDictionaryOfMixed, + insertIntoListOfMixed, + isJsOrRealmDictionary, + isJsOrRealmList, + toItemType, } from "./internal"; type PropertyContext = binding.Property & { @@ -39,13 +48,6 @@ type PropertyContext = binding.Property & { default?: unknown; }; -function getObj(results: binding.Results, index: number) { - return results.getObj(index); -} -function getAny(results: binding.Results, index: number) { - return results.getAny(index); -} - /** @internal */ export type HelperOptions = { realm: Realm; @@ -60,15 +62,15 @@ type PropertyOptions = { } & HelperOptions & binding.Property_Relaxed; -type PropertyAccessors = { +type PropertyAccessor = { get(obj: binding.Obj): unknown; set(obj: binding.Obj, value: unknown): unknown; - collectionHelpers?: OrderedCollectionHelpers; + listAccessor?: ListAccessor; }; /** @internal */ export type PropertyHelpers = TypeHelpers & - PropertyAccessors & { + PropertyAccessor & { type: binding.PropertyType; columnKey: binding.ColKey; embedded: boolean; @@ -116,7 +118,7 @@ function embeddedSet({ typeHelpers: { toBinding }, columnKey }: PropertyOptions) }; } -type AccessorFactory = (options: PropertyOptions) => PropertyAccessors; +type AccessorFactory = (options: PropertyOptions) => PropertyAccessor; const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> = { [binding.PropertyType.Object](options) { @@ -153,11 +155,9 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> linkOriginPropertyName, getClassHelpers, optional, - typeHelpers: { fromBinding }, }) { const realmInternal = realm.internal; - const itemType = type & ~binding.PropertyType.Flags; - + const itemType = toItemType(type); const itemHelpers = getTypeHelpers(itemType, { realm, name: `element of ${name}`, @@ -167,13 +167,6 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> objectSchemaName: undefined, }); - // Properties of items are only available on lists of objects - const isObjectItem = itemType === binding.PropertyType.Object || itemType === binding.PropertyType.LinkingObjects; - const collectionHelpers: OrderedCollectionHelpers = { - ...itemHelpers, - get: isObjectItem ? getObj : getAny, - }; - if (itemType === binding.PropertyType.LinkingObjects) { // Locate the table of the targeted object assert.string(objectType, "object type"); @@ -186,71 +179,51 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> const targetProperty = persistedProperties.find((p) => p.name === linkOriginPropertyName); assert(targetProperty, `Expected a '${linkOriginPropertyName}' property on ${objectType}`); const tableRef = binding.Helpers.getTable(realmInternal, tableKey); + const resultsAccessor = createResultsAccessor({ realm, typeHelpers: itemHelpers, itemType }); return { get(obj: binding.Obj) { const tableView = obj.getBacklinkView(tableRef, targetProperty.columnKey); const results = binding.Results.fromTableView(realmInternal, tableView); - return new Results(realm, results, collectionHelpers); + return new Results(realm, results, resultsAccessor, itemHelpers); }, set() { throw new Error("Not supported"); }, }; } else { - const { toBinding: itemToBinding } = itemHelpers; + const listAccessor = createListAccessor({ realm, typeHelpers: itemHelpers, itemType, isEmbedded: embedded }); + return { - collectionHelpers, + listAccessor, get(obj: binding.Obj) { const internal = binding.List.make(realm.internal, obj, columnKey); assert.instanceOf(internal, binding.List); - return fromBinding(internal); + return new List(realm, internal, listAccessor, itemHelpers); }, set(obj, values) { assert.inTransaction(realm); - // Implements https://github.com/realm/realm-core/blob/v12.0.0/src/realm/object-store/list.hpp#L258-L286 assert.iterable(values); - const bindingValues = []; - const internal = binding.List.make(realm.internal, obj, columnKey); - // In case of embedded objects, they're added as they're transformed - // So we need to ensure an empty list before - if (embedded) { - internal.removeAll(); - } - // Transform all values to mixed before inserting into the list - { - let index = 0; + const internal = binding.List.make(realm.internal, obj, columnKey); + internal.removeAll(); + let index = 0; + try { for (const value of values) { - try { - if (embedded) { - itemToBinding(value, { createObj: () => [internal.insertEmbedded(index), true] }); - } else { - bindingValues.push(itemToBinding(value)); - } - } catch (err) { - if (err instanceof TypeAssertionError) { - err.rename(`${name}[${index}]`); - } - throw err; - } - index++; + listAccessor.insert(internal, index++, value); } - } - // Move values into the internal list - embedded objects are added as they're transformed - if (!embedded) { - internal.removeAll(); - let index = 0; - for (const value of bindingValues) { - internal.insertAny(index++, value); + } catch (err) { + if (err instanceof TypeAssertionError) { + err.rename(`${name}[${index - 1}]`); } + throw err; } }, }; } }, [binding.PropertyType.Dictionary]({ columnKey, realm, name, type, optional, objectType, getClassHelpers, embedded }) { - const itemType = type & ~binding.PropertyType.Flags; + const itemType = toItemType(type); const itemHelpers = getTypeHelpers(itemType, { realm, name: `value in ${name}`, @@ -259,23 +232,28 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> optional, objectSchemaName: undefined, }); + const dictionaryAccessor = createDictionaryAccessor({ + realm, + typeHelpers: itemHelpers, + itemType, + isEmbedded: embedded, + }); + return { get(obj) { const internal = binding.Dictionary.make(realm.internal, obj, columnKey); - return new Dictionary(realm, internal, itemHelpers); + return new Dictionary(realm, internal, dictionaryAccessor, itemHelpers); }, set(obj, value) { + assert.inTransaction(realm); + const internal = binding.Dictionary.make(realm.internal, obj, columnKey); // Clear the dictionary before adding new values internal.removeAll(); - assert.object(value, `values of ${name}`); + assert.object(value, `values of ${name}`, { allowArrays: false }); for (const [k, v] of Object.entries(value)) { try { - if (embedded) { - itemHelpers.toBinding(v, { createObj: () => [internal.insertEmbedded(k), true] }); - } else { - internal.insertAny(k, itemHelpers.toBinding(v)); - } + dictionaryAccessor.set(internal, k, v); } catch (err) { if (err instanceof TypeAssertionError) { err.rename(`${name}["${k}"]`); @@ -287,7 +265,7 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> }; }, [binding.PropertyType.Set]({ columnKey, realm, name, type, optional, objectType, getClassHelpers }) { - const itemType = type & ~binding.PropertyType.Flags; + const itemType = toItemType(type); const itemHelpers = getTypeHelpers(itemType, { realm, name: `value in ${name}`, @@ -297,76 +275,64 @@ const ACCESSOR_FACTORIES: Partial<Record<binding.PropertyType, AccessorFactory>> objectSchemaName: undefined, }); assert.string(objectType); - const collectionHelpers: OrderedCollectionHelpers = { - get: itemType === binding.PropertyType.Object ? getObj : getAny, - fromBinding: itemHelpers.fromBinding, - toBinding: itemHelpers.toBinding, - }; + const setAccessor = createSetAccessor({ realm, typeHelpers: itemHelpers, itemType }); + return { get(obj) { const internal = binding.Set.make(realm.internal, obj, columnKey); - return new RealmSet(realm, internal, collectionHelpers); + return new RealmSet(realm, internal, setAccessor, itemHelpers); }, set(obj, value) { + assert.inTransaction(realm); + const internal = binding.Set.make(realm.internal, obj, columnKey); // Clear the set before adding new values internal.removeAll(); assert.array(value, "values"); for (const v of value) { - internal.insertAny(itemHelpers.toBinding(v)); + setAccessor.insert(internal, v); } }, }; }, [binding.PropertyType.Mixed](options) { - const { - realm, - columnKey, - typeHelpers: { fromBinding, toBinding }, - } = options; + const { realm, columnKey, typeHelpers } = options; + const { fromBinding, toBinding } = typeHelpers; + const listAccessor = createListAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + const dictionaryAccessor = createDictionaryAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); return { - get: (obj) => { + get(obj) { try { - // We currently rely on the Core helper `get_mixed_type()` for calling `obj.get_any()` - // since doing it here in the SDK layer will cause the binding layer to throw for - // collections. It's non-trivial to do in the bindgen templates as a `binding.List` - // would have to be constructed using the `realm` and `obj`. Going via the helpers - // bypasses that as we will return a primitive (the data type). If possible, revisiting - // this for a more performant solution would be ideal as we now make an extra call into - // Core for each Mixed access, not only for collections. - const mixedType = binding.Helpers.getMixedType(obj, columnKey); - if (mixedType === binding.MixedDataType.List) { - return fromBinding(binding.List.make(realm.internal, obj, columnKey)); - } - if (mixedType === binding.MixedDataType.Dictionary) { - return fromBinding(binding.Dictionary.make(realm.internal, obj, columnKey)); + const value = obj.getAny(columnKey); + switch (value) { + case binding.ListSentinel: { + const internal = binding.List.make(realm.internal, obj, columnKey); + return new List(realm, internal, listAccessor, typeHelpers); + } + case binding.DictionarySentinel: { + const internal = binding.Dictionary.make(realm.internal, obj, columnKey); + return new Dictionary(realm, internal, dictionaryAccessor, typeHelpers); + } + default: + return fromBinding(value); } - return defaultGet(options)(obj); } catch (err) { assert.isValid(obj); throw err; } }, - set: (obj: binding.Obj, value: unknown) => { + set(obj: binding.Obj, value: unknown) { assert.inTransaction(realm); - if (value instanceof List || Array.isArray(value)) { + if (isJsOrRealmList(value)) { obj.setCollection(columnKey, binding.CollectionType.List); const internal = binding.List.make(realm.internal, obj, columnKey); - let index = 0; - for (const item of value) { - internal.insertAny(index++, toBinding(item)); - } - } else if (value instanceof Dictionary || isPOJO(value)) { + insertIntoListOfMixed(value, internal, toBinding); + } else if (isJsOrRealmDictionary(value)) { obj.setCollection(columnKey, binding.CollectionType.Dictionary); const internal = binding.Dictionary.make(realm.internal, obj, columnKey); - internal.removeAll(); - for (const key in value) { - internal.insertAny(key, toBinding(value[key])); - } - } else if (value instanceof RealmSet || value instanceof Set) { - throw new Error(`Using a ${value.constructor.name} as a Mixed value is not supported.`); + insertIntoDictionaryOfMixed(value, internal, toBinding); } else { defaultSet(options)(obj, value); } @@ -413,23 +379,12 @@ export function createPropertyHelpers(property: PropertyContext, options: Helper typeHelpers: getTypeHelpers(collectionType, typeOptions), }); } else { - const baseType = property.type & ~binding.PropertyType.Flags; - return getPropertyHelpers(baseType, { + const itemType = toItemType(property.type); + return getPropertyHelpers(itemType, { ...property, ...options, ...typeOptions, - typeHelpers: getTypeHelpers(baseType, typeOptions), + typeHelpers: getTypeHelpers(itemType, typeOptions), }); } } - -/** @internal */ -export function isPOJO(value: unknown): value is Record<string, unknown> { - return ( - typeof value === "object" && - value !== null && - // Lastly check for the absence of a prototype as POJOs - // can still be created using `Object.create(null)`. - (value.constructor === Object || !Object.getPrototypeOf(value)) - ); -} diff --git a/packages/realm/src/Realm.ts b/packages/realm/src/Realm.ts index 5ed5aab8e7..8554852cec 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -48,10 +48,12 @@ import { SubscriptionSet, SyncSession, TypeAssertionError, + TypeHelpers, Unmanaged, UpdateMode, assert, binding, + createResultsAccessor, defaultLogger, defaultLoggerLevel, extendDebug, @@ -938,25 +940,27 @@ export class Realm { objects<T = DefaultObject>(type: string): Results<RealmObject<T> & T>; objects<T extends AnyRealmObject = RealmObject & DefaultObject>(type: Constructor<T>): Results<T>; objects<T extends AnyRealmObject>(type: string | Constructor<T>): Results<T> { - const { objectSchema, wrapObject } = this.classes.getHelpers(type); + const { internal, classes } = this; + const { objectSchema, wrapObject } = classes.getHelpers(type); if (isEmbedded(objectSchema)) { throw new Error("You cannot query an embedded object."); } else if (isAsymmetric(objectSchema)) { throw new Error("You cannot query an asymmetric object."); } - const table = binding.Helpers.getTable(this.internal, objectSchema.tableKey); - const results = binding.Results.fromTable(this.internal, table); - return new Results<T>(this, results, { - get(results: binding.Results, index: number) { - return results.getObj(index); + const table = binding.Helpers.getTable(internal, objectSchema.tableKey); + const results = binding.Results.fromTable(internal, table); + const typeHelpers: TypeHelpers<T> = { + fromBinding(value) { + return wrapObject(value as binding.Obj) as T; }, - fromBinding: wrapObject, - toBinding(value: unknown) { + toBinding(value) { assert.instanceOf(value, RealmObject); return value[INTERNAL]; }, - }); + }; + const accessor = createResultsAccessor<T>({ realm: this, typeHelpers, itemType: binding.PropertyType.Object }); + return new Results<T>(this, results, accessor, typeHelpers); } /** diff --git a/packages/realm/src/Results.ts b/packages/realm/src/Results.ts index 962f3a776e..41e3560247 100644 --- a/packages/realm/src/Results.ts +++ b/packages/realm/src/Results.ts @@ -17,16 +17,22 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, + Dictionary, IllegalConstructorError, + List, OrderedCollection, - OrderedCollectionHelpers, Realm, SubscriptionOptions, TimeoutPromise, + TypeHelpers, Unmanaged, WaitForSync, assert, binding, + createDefaultGetter, + createDictionaryAccessor, + createListAccessor, } from "./internal"; /** @@ -38,12 +44,18 @@ import { * will thus never be called). * @see https://www.mongodb.com/docs/realm/sdk/react-native/model-data/data-types/collections/ */ -export class Results<T = unknown> extends OrderedCollection<T> { +export class Results<T = unknown> extends OrderedCollection< + T, + [number, T], + /** @internal */ + ResultsAccessor<T> +> { /** * The representation in the binding. * @internal */ - public declare internal: binding.Results; + public declare readonly internal: binding.Results; + /** @internal */ public subscriptionName?: string; @@ -51,11 +63,12 @@ export class Results<T = unknown> extends OrderedCollection<T> { * Create a `Results` wrapping a set of query `Results` from the binding. * @internal */ - constructor(realm: Realm, internal: binding.Results, helpers: OrderedCollectionHelpers) { + constructor(realm: Realm, internal: binding.Results, accessor: ResultsAccessor<T>, typeHelpers: TypeHelpers<T>) { if (arguments.length === 0 || !(internal instanceof binding.Results)) { throw new IllegalConstructorError("Results"); } - super(realm, internal, helpers); + super(realm, internal, accessor, typeHelpers); + Object.defineProperty(this, "internal", { enumerable: false, configurable: false, @@ -75,6 +88,16 @@ export class Results<T = unknown> extends OrderedCollection<T> { }); } + /** @internal */ + public get(index: number): T { + return this[ACCESSOR].get(this.internal, index); + } + + /** @internal */ + public set(): never { + throw new Error("Modifying a Results collection is not supported."); + } + get length(): number { return this.internal.size(); } @@ -98,20 +121,16 @@ export class Results<T = unknown> extends OrderedCollection<T> { * @since 2.0.0 */ update(propertyName: keyof Unmanaged<T>, value: Unmanaged<T>[typeof propertyName]): void { - const { - classHelpers, - helpers: { get }, - } = this; assert.string(propertyName); - assert(this.type === "object" && classHelpers, "Expected a result of Objects"); - const { set } = classHelpers.properties.get(propertyName); - - const snapshot = this.results.snapshot(); + const { classHelpers, type, results } = this; + assert(type === "object" && classHelpers, "Expected a result of Objects"); + const { set: objectSet } = classHelpers.properties.get(propertyName); + const snapshot = results.snapshot(); const size = snapshot.size(); for (let i = 0; i < size; i++) { - const obj = get(snapshot, i); + const obj = snapshot.getObj(i); assert.instanceOf(obj, binding.Obj); - set(obj, value); + objectSet(obj, value); } } @@ -177,5 +196,58 @@ export class Results<T = unknown> extends OrderedCollection<T> { } } +/** + * Accessor for getting items from the binding collection. + * @internal + */ +export type ResultsAccessor<T = unknown> = { + get: (results: binding.Results, index: number) => T; +}; + +type ResultsAccessorFactoryOptions<T> = { + realm: Realm; + typeHelpers: TypeHelpers<T>; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createResultsAccessor<T>(options: ResultsAccessorFactoryOptions<T>): ResultsAccessor<T> { + return options.itemType === binding.PropertyType.Mixed + ? createResultsAccessorForMixed(options) + : createResultsAccessorForKnownType(options); +} + +function createResultsAccessorForMixed<T>({ + realm, + typeHelpers, +}: Omit<ResultsAccessorFactoryOptions<T>, "itemType">): ResultsAccessor<T> { + return { + get(results, index) { + const value = results.getAny(index); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor<T>({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new List<T>(realm, results.getList(index), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor<T>({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new Dictionary<T>(realm, results.getDictionary(index), accessor, typeHelpers) as T; + } + default: + return typeHelpers.fromBinding(value); + } + }, + }; +} + +function createResultsAccessorForKnownType<T>({ + typeHelpers, + itemType, +}: Omit<ResultsAccessorFactoryOptions<T>, "realm">): ResultsAccessor<T> { + return { + get: createDefaultGetter({ fromBinding: typeHelpers.fromBinding, itemType }), + }; +} + /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Useful for APIs taking any `Results` */ export type AnyResults = Results<any>; diff --git a/packages/realm/src/Set.ts b/packages/realm/src/Set.ts index 37e1c8c1af..237dc3a51e 100644 --- a/packages/realm/src/Set.ts +++ b/packages/realm/src/Set.ts @@ -17,12 +17,15 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, IllegalConstructorError, OrderedCollection, - OrderedCollectionHelpers, Realm, + COLLECTION_TYPE_HELPERS as TYPE_HELPERS, + TypeHelpers, assert, binding, + createDefaultGetter, } from "./internal"; /** @@ -39,16 +42,22 @@ import { * a user-supplied insertion order. * @see https://www.mongodb.com/docs/realm/sdk/react-native/model-data/data-types/sets/ */ -export class RealmSet<T = unknown> extends OrderedCollection<T, [T, T]> { +export class RealmSet<T = unknown> extends OrderedCollection< + T, + [T, T], /** @internal */ - private declare internal: binding.Set; + SetAccessor<T> +> { + /** @internal */ + public declare readonly internal: binding.Set; /** @internal */ - constructor(realm: Realm, internal: binding.Set, helpers: OrderedCollectionHelpers) { + constructor(realm: Realm, internal: binding.Set, accessor: SetAccessor<T>, typeHelpers: TypeHelpers<T>) { if (arguments.length === 0 || !(internal instanceof binding.Set)) { throw new IllegalConstructorError("Set"); } - super(realm, internal.asResults(), helpers); + super(realm, internal.asResults(), accessor, typeHelpers); + Object.defineProperty(this, "internal", { enumerable: false, configurable: false, @@ -56,6 +65,17 @@ export class RealmSet<T = unknown> extends OrderedCollection<T, [T, T]> { value: internal, }); } + + /** @internal */ + public get(index: number): T { + return this[ACCESSOR].get(this.internal, index); + } + + /** @internal */ + public set(index: number, value: T): void { + this[ACCESSOR].set(this.internal, index, value); + } + /** * @returns The number of values in the Set. */ @@ -79,7 +99,7 @@ export class RealmSet<T = unknown> extends OrderedCollection<T, [T, T]> { */ delete(value: T): boolean { assert.inTransaction(this.realm); - const [, success] = this.internal.removeAny(this.helpers.toBinding(value)); + const [, success] = this.internal.removeAny(this[TYPE_HELPERS].toBinding(value)); return success; } @@ -92,8 +112,7 @@ export class RealmSet<T = unknown> extends OrderedCollection<T, [T, T]> { * @returns The Set itself, after adding the new value. */ add(value: T): this { - assert.inTransaction(this.realm); - this.internal.insertAny(this.helpers.toBinding(value)); + this[ACCESSOR].insert(this.internal, value); return this; } @@ -129,3 +148,85 @@ export class RealmSet<T = unknown> extends OrderedCollection<T, [T, T]> { } } } + +/** + * Accessor for getting and setting items in the binding collection. + * @internal + */ +export type SetAccessor<T = unknown> = { + get: (set: binding.Set, index: number) => T; + set: (set: binding.Set, index: number, value: T) => void; + insert: (set: binding.Set, value: T) => void; +}; + +type SetAccessorFactoryOptions<T> = { + realm: Realm; + typeHelpers: TypeHelpers<T>; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createSetAccessor<T>(options: SetAccessorFactoryOptions<T>): SetAccessor<T> { + return options.itemType === binding.PropertyType.Mixed + ? createSetAccessorForMixed<T>(options) + : createSetAccessorForKnownType<T>(options); +} + +function createSetAccessorForMixed<T>({ + realm, + typeHelpers, +}: Omit<SetAccessorFactoryOptions<T>, "itemType">): SetAccessor<T> { + const { fromBinding, toBinding } = typeHelpers; + return { + get(set, index) { + // Core will not return collections within a Set. + return fromBinding(set.getAny(index)); + }, + // Directly setting by "index" to a Set is a no-op. + set: () => {}, + insert(set, value) { + assert.inTransaction(realm); + + try { + set.insertAny(toBinding(value)); + } catch (err) { + // Optimize for the valid cases by not guarding for the unsupported nested collections upfront. + throw transformError(err); + } + }, + }; +} + +function createSetAccessorForKnownType<T>({ + realm, + typeHelpers, + itemType, +}: SetAccessorFactoryOptions<T>): SetAccessor<T> { + const { fromBinding, toBinding } = typeHelpers; + return { + get: createDefaultGetter({ fromBinding, itemType }), + // Directly setting by "index" to a Set is a no-op. + set: () => {}, + insert(set, value) { + assert.inTransaction(realm); + + try { + set.insertAny(toBinding(value)); + } catch (err) { + // Optimize for the valid cases by not guarding for the unsupported nested collections upfront. + throw transformError(err); + } + }, + }; +} + +function transformError(err: unknown) { + const message = err instanceof Error ? err.message : ""; + if (message?.includes("'Array' to a Mixed") || message?.includes("'List' to a Mixed")) { + return new Error("Lists within a Set are not supported."); + } + if (message?.includes("'Object' to a Mixed") || message?.includes("'Dictionary' to a Mixed")) { + return new Error("Dictionaries within a Set are not supported."); + } + return err; +} diff --git a/packages/realm/src/TypeHelpers.ts b/packages/realm/src/TypeHelpers.ts index b1a02da03c..9de5f0d53e 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -21,13 +21,9 @@ import { ClassHelpers, Collection, Dictionary, - GeoBox, - GeoCircle, - GeoPolygon, INTERNAL, List, ObjCreator, - OrderedCollectionHelpers, REALM, Realm, RealmObject, @@ -38,6 +34,11 @@ import { binding, boxToBindingGeospatial, circleToBindingGeospatial, + createDictionaryAccessor, + createListAccessor, + isGeoBox, + isGeoCircle, + isGeoPolygon, polygonToBindingGeospatial, safeGlobalThis, } from "./internal"; @@ -73,7 +74,10 @@ export function toArrayBuffer(value: unknown, stringToBase64 = true) { return value; } -/** @internal */ +/** + * Helpers for converting a value to and from its binding representation. + * @internal + */ export type TypeHelpers<T = unknown> = { toBinding( value: T, @@ -143,12 +147,14 @@ export function mixedToBinding( } } } + // Convert typed arrays to an `ArrayBuffer` for (const TypedArray of TYPED_ARRAY_CONSTRUCTORS) { if (value instanceof TypedArray) { return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); } } + // Rely on the binding for any other value return value as binding.MixedArg; } @@ -168,40 +174,23 @@ function mixedFromBinding(options: TypeOptions, value: binding.MixedArg): unknow const { wrapObject } = getClassHelpers(value.tableKey); return wrapObject(linkedObj); } else if (value instanceof binding.List) { - const collectionHelpers: OrderedCollectionHelpers = { - toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding: mixedFromBinding.bind(null, options), - get(_: binding.Results, index: number) { - return value.getAny(index); - }, - }; - return new List(realm, value, collectionHelpers); + const mixedType = binding.PropertyType.Mixed; + const typeHelpers = getTypeHelpers(mixedType, options); + return new List(realm, value, createListAccessor({ realm, typeHelpers, itemType: mixedType }), typeHelpers); } else if (value instanceof binding.Dictionary) { - const typeHelpers: TypeHelpers<Realm.Mixed> = { - toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding: mixedFromBinding.bind(null, options), - }; - return new Dictionary(realm, value, typeHelpers); + const mixedType = binding.PropertyType.Mixed; + const typeHelpers = getTypeHelpers(mixedType, options); + return new Dictionary( + realm, + value, + createDictionaryAccessor({ realm, typeHelpers, itemType: mixedType }), + typeHelpers, + ); } else { return value; } } -function isGeoCircle(value: object): value is GeoCircle { - return "distance" in value && "center" in value && typeof value["distance"] === "number"; -} - -function isGeoBox(value: object): value is GeoBox { - return "bottomLeft" in value && "topRight" in value; -} - -function isGeoPolygon(value: object): value is GeoPolygon { - return ( - ("type" in value && value["type"] === "Polygon" && "coordinates" in value && Array.isArray(value["coordinates"])) || - ("outerRing" in value && Array.isArray(value["outerRing"])) - ); -} - function defaultToBinding(value: unknown): binding.MixedArg { return value as binding.MixedArg; } @@ -356,9 +345,8 @@ const TYPES_MAPPING: Record<binding.PropertyType, (options: TypeOptions) => Type }; }, [binding.PropertyType.Mixed](options) { - const { realm } = options; return { - toBinding: mixedToBinding.bind(null, realm.internal), + toBinding: mixedToBinding.bind(null, options.realm.internal), fromBinding: mixedFromBinding.bind(null, options), }; }, @@ -392,13 +380,14 @@ const TYPES_MAPPING: Record<binding.PropertyType, (options: TypeOptions) => Type [binding.PropertyType.Array]({ realm, getClassHelpers, name, objectSchemaName }) { assert.string(objectSchemaName, "objectSchemaName"); const classHelpers = getClassHelpers(objectSchemaName); + return { fromBinding(value: unknown) { assert.instanceOf(value, binding.List); const propertyHelpers = classHelpers.properties.get(name); - const collectionHelpers = propertyHelpers.collectionHelpers; - assert.object(collectionHelpers); - return new List(realm, value, collectionHelpers); + const { listAccessor } = propertyHelpers; + assert.object(listAccessor); + return new List(realm, value, listAccessor, propertyHelpers); }, toBinding() { throw new Error("Not supported"); @@ -437,6 +426,10 @@ const TYPES_MAPPING: Record<binding.PropertyType, (options: TypeOptions) => Type }; /** @internal */ +export function toItemType(type: binding.PropertyType) { + return type & ~binding.PropertyType.Flags; +} + export function getTypeHelpers(type: binding.PropertyType, options: TypeOptions): TypeHelpers { const helpers = TYPES_MAPPING[type]; assert(helpers, `Unexpected type ${type}`); diff --git a/packages/realm/src/tests/PropertyHelpers.test.ts b/packages/realm/src/tests/collection-helpers.test.ts similarity index 83% rename from packages/realm/src/tests/PropertyHelpers.test.ts rename to packages/realm/src/tests/collection-helpers.test.ts index 685a3784b7..b80cd2db43 100644 --- a/packages/realm/src/tests/PropertyHelpers.test.ts +++ b/packages/realm/src/tests/collection-helpers.test.ts @@ -18,9 +18,9 @@ import { expect } from "chai"; -import { isPOJO } from "../PropertyHelpers"; +import { isPOJO } from "../Dictionary"; -describe("PropertyHelpers", () => { +describe("Collection helpers", () => { describe("isPOJO()", () => { it("returns true for object literal", () => { const object = {}; @@ -53,12 +53,13 @@ describe("PropertyHelpers", () => { expect(isPOJO(object)).to.be.false; }); - it("returns false for user-defined class called Object", () => { - class Object {} - const object = new Object(); - expect(object.constructor).to.equal(Object); - expect(isPOJO(object)).to.be.false; - }); + // TS2725 compile error: "Class name cannot be 'Object' when targeting ES5 with module Node16" + // it("returns false for user-defined class called Object", () => { + // class Object {} + // const object = new Object(); + // expect(object.constructor).to.equal(Object); + // expect(isPOJO(object)).to.be.false; + // }); it("returns false for Arrays", () => { expect(isPOJO([])).to.be.false; From aeb1749098453c3ad1b4f2c7defda4058e3c3b65 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Thu, 11 Apr 2024 22:53:32 +0200 Subject: [PATCH 03/43] Temporarily comment out failing filtering tests from Core upgrade. --- integration-tests/tests/src/tests/mixed.ts | 66 +++++++++++++--------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 1441ff024e..4551f32ddd 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -2146,8 +2146,9 @@ describe("Mixed", () => { filtered = objects.filtered(`mixed[*] == $0`, nonExistentValue); expect(filtered.length).equals(0); - filtered = objects.filtered(`mixed[${nonExistentIndex}][*] == $0`, itemToMatch); - expect(filtered.length).equals(0); + // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[${nonExistentIndex}][*] == $0`, itemToMatch); + // expect(filtered.length).equals(0); index++; } @@ -2246,25 +2247,29 @@ describe("Mixed", () => { for (const itemToMatch of nestedList) { // Objects with a nested list item that matches the `itemToMatch` at the GIVEN index. + // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) let filtered = objects.filtered(`mixed[0][0][${index}] == $0`, itemToMatch); - expect(filtered.length).equals(expectedFilteredCount); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed[0][0][${index}] == $0`, nonExistentValue); expect(filtered.length).equals(0); - filtered = objects.filtered(`mixed[0][0][${nonExistentIndex}] == $0`, itemToMatch); - expect(filtered.length).equals(0); + // TODO: Core bug? (When `itemToMatch` is `null`, this returns 2 objects -- the objects whose mixed fields are strings.) + // filtered = objects.filtered(`mixed[0][0][${nonExistentIndex}] == $0`, itemToMatch); + // expect(filtered.length).equals(0); // Objects with a nested list item that matches the `itemToMatch` at ANY index. - filtered = objects.filtered(`mixed[0][0][*] == $0`, itemToMatch); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[0][0][*] == $0`, itemToMatch); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed[0][0][*] == $0`, nonExistentValue); expect(filtered.length).equals(0); - filtered = objects.filtered(`mixed[0][${nonExistentIndex}][*] == $0`, itemToMatch); - expect(filtered.length).equals(0); + // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[0][${nonExistentIndex}][*] == $0`, itemToMatch); + // expect(filtered.length).equals(0); index++; } @@ -2296,8 +2301,9 @@ describe("Mixed", () => { // Objects with a nested list containing an item of the given type. - filtered = objects.filtered(`mixed[0][0][*].@type == 'null'`); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (This returns all objects.) + // filtered = objects.filtered(`mixed[0][0][*].@type == 'null'`); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed[0][0][*].@type == 'bool'`); expect(filtered.length).equals(expectedFilteredCount); @@ -2403,8 +2409,9 @@ describe("Mixed", () => { // Objects with a dictionary value at the given key matching any of the values inserted. - filtered = objects.filtered(`mixed.${key} IN $0`, insertedValues); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (For all keys, this returns 0 objects.) + // filtered = objects.filtered(`mixed.${key} IN $0`, insertedValues); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.${key} IN $0`, [nonExistentValue]); expect(filtered.length).equals(0); @@ -2507,30 +2514,35 @@ describe("Mixed", () => { // Objects with a nested dictionary value that matches the `valueToMatch` at the GIVEN key. + // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) let filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, nonExistentValue); expect(filtered.length).equals(0); - filtered = objects.filtered(`mixed['depth1']['depth2']['${nonExistentKey}'] == $0`, valueToMatch); + // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed['depth1']['depth2']['${nonExistentKey}'] == $0`, valueToMatch); // Core treats missing keys as `null` in queries. - expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); - filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, valueToMatch); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, nonExistentValue); expect(filtered.length).equals(0); - filtered = objects.filtered(`mixed.depth1.depth2.${nonExistentKey} == $0`, valueToMatch); + // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2.${nonExistentKey} == $0`, valueToMatch); // Core treats missing keys as `null` in queries. - expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); // Objects with a nested dictionary value that matches the `valueToMatch` at ANY key. - filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, valueToMatch); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, valueToMatch); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, nonExistentValue); expect(filtered.length).equals(0); @@ -2545,8 +2557,9 @@ describe("Mixed", () => { // Objects with a nested dictionary value at the given key matching any of the values inserted. - filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, insertedValues); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (For all keys, this returns 2 objects -- the objects whose mixed fields are strings.) + // filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, insertedValues); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, [nonExistentValue]); expect(filtered.length).equals(0); @@ -2579,8 +2592,9 @@ describe("Mixed", () => { // Objects with a nested dictionary containing a property of the given type. - filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'null'`); - expect(filtered.length).equals(expectedFilteredCount); + // TODO: Core bug? (This returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'null'`); + // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'bool'`); expect(filtered.length).equals(expectedFilteredCount); From b7d21e7600b709f5e43f52f3a3b338d5c9b21c04 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:54:41 +0200 Subject: [PATCH 04/43] Add links to Core issue. --- integration-tests/tests/src/tests/mixed.ts | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 4551f32ddd..974bfda254 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -2146,7 +2146,7 @@ describe("Mixed", () => { filtered = objects.filtered(`mixed[*] == $0`, nonExistentValue); expect(filtered.length).equals(0); - // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed[${nonExistentIndex}][*] == $0`, itemToMatch); // expect(filtered.length).equals(0); @@ -2247,27 +2247,27 @@ describe("Mixed", () => { for (const itemToMatch of nestedList) { // Objects with a nested list item that matches the `itemToMatch` at the GIVEN index. - // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) let filtered = objects.filtered(`mixed[0][0][${index}] == $0`, itemToMatch); // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed[0][0][${index}] == $0`, nonExistentValue); expect(filtered.length).equals(0); - // TODO: Core bug? (When `itemToMatch` is `null`, this returns 2 objects -- the objects whose mixed fields are strings.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns 2 objects -- the objects whose mixed fields are strings.) // filtered = objects.filtered(`mixed[0][0][${nonExistentIndex}] == $0`, itemToMatch); // expect(filtered.length).equals(0); // Objects with a nested list item that matches the `itemToMatch` at ANY index. - // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed[0][0][*] == $0`, itemToMatch); // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed[0][0][*] == $0`, nonExistentValue); expect(filtered.length).equals(0); - // TODO: Core bug? (When `itemToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed[0][${nonExistentIndex}][*] == $0`, itemToMatch); // expect(filtered.length).equals(0); @@ -2301,7 +2301,7 @@ describe("Mixed", () => { // Objects with a nested list containing an item of the given type. - // TODO: Core bug? (This returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (This returns all objects.) // filtered = objects.filtered(`mixed[0][0][*].@type == 'null'`); // expect(filtered.length).equals(expectedFilteredCount); @@ -2409,7 +2409,7 @@ describe("Mixed", () => { // Objects with a dictionary value at the given key matching any of the values inserted. - // TODO: Core bug? (For all keys, this returns 0 objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (For all keys, this returns 0 objects.) // filtered = objects.filtered(`mixed.${key} IN $0`, insertedValues); // expect(filtered.length).equals(expectedFilteredCount); @@ -2514,33 +2514,33 @@ describe("Mixed", () => { // Objects with a nested dictionary value that matches the `valueToMatch` at the GIVEN key. - // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) let filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, valueToMatch); // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, nonExistentValue); expect(filtered.length).equals(0); - // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed['depth1']['depth2']['${nonExistentKey}'] == $0`, valueToMatch); // Core treats missing keys as `null` in queries. // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); - // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, valueToMatch); // expect(filtered.length).equals(expectedFilteredCount); filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, nonExistentValue); expect(filtered.length).equals(0); - // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed.depth1.depth2.${nonExistentKey} == $0`, valueToMatch); // Core treats missing keys as `null` in queries. // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); // Objects with a nested dictionary value that matches the `valueToMatch` at ANY key. - // TODO: Core bug? (When `valueToMatch` is `null`, this returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) // filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, valueToMatch); // expect(filtered.length).equals(expectedFilteredCount); @@ -2557,7 +2557,7 @@ describe("Mixed", () => { // Objects with a nested dictionary value at the given key matching any of the values inserted. - // TODO: Core bug? (For all keys, this returns 2 objects -- the objects whose mixed fields are strings.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (For all keys, this returns 2 objects -- the objects whose mixed fields are strings.) // filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, insertedValues); // expect(filtered.length).equals(expectedFilteredCount); @@ -2592,7 +2592,7 @@ describe("Mixed", () => { // Objects with a nested dictionary containing a property of the given type. - // TODO: Core bug? (This returns all objects.) + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (This returns all objects.) // filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'null'`); // expect(filtered.length).equals(expectedFilteredCount); From c20dedebdb6ef5bcf00ccf5bcb8b3d087e0c88c8 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:01:03 +0200 Subject: [PATCH 05/43] Update example in CHANGELOG. --- CHANGELOG.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f92d885ee6..acaf8b7090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ ### Enhancements * A `mixed` value can now hold a `Realm.List` and `Realm.Dictionary` with nested collections. Note that `Realm.Set` is not supported as a `mixed` value. ([#6513](https://github.com/realm/realm-js/pull/6513)) - ```typescript class CustomObject extends Realm.Object { - value!: Realm.Mixed; + value!: Realm.Types.Mixed; static schema: ObjectSchema = { name: "CustomObject", @@ -17,8 +16,8 @@ class CustomObject extends Realm.Object { const realm = await Realm.open({ schema: [CustomObject] }); -// Create an object with a dictionary value as the Mixed property, -// containing primitives and a list. +// Create an object with a dictionary value as the Mixed +// property, containing primitives and a list. const realmObject = realm.write(() => { return realm.create(CustomObject, { value: { @@ -27,9 +26,7 @@ const realmObject = realm.write(() => { bool: true, list: [ { - dict: { - string: "world", - }, + string: "world", }, ], }, @@ -37,16 +34,30 @@ const realmObject = realm.write(() => { }); // Accessing the collection value returns the managed collection. -// The default generic type argument is `unknown` (mixed). -const dictionary = realmObject.value as Realm.Dictionary; -const list = dictionary.list as Realm.List; -const leafDictionary = (list[0] as Realm.Dictionary).dict as Realm.Dictionary; +const dictionary = realmObject.value; +expectDictionary(dictionary); +const list = dictionary.list; +expectList(list); +const leafDictionary = list[0]; +expectDictionary(leafDictionary); console.log(leafDictionary.string); // "world" // Update the Mixed property to a list. realm.write(() => { realmObject.value = [1, "hello", { newKey: "new value" }]; }); + +// Useful custom helper functions. (Will be provided in a future release.) +function expectList(value: unknown): asserts value is Realm.List { + if (!(value instanceof Realm.List)) { + throw new Error("Expected a 'Realm.List'."); + } +} +function expectDictionary(value: unknown): asserts value is Realm.Dictionary { + if (!(value instanceof Realm.Dictionary)) { + throw new Error("Expected a 'Realm.Dictionary'."); + } +} ``` ### Fixed From 1dca996b04b2603447d046d06821c8922d235211 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:22:28 +0200 Subject: [PATCH 06/43] Update PR link in CHANGELOG to the combined PR. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acaf8b7090..4d93a88194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## vNext (TBD) ### Enhancements -* A `mixed` value can now hold a `Realm.List` and `Realm.Dictionary` with nested collections. Note that `Realm.Set` is not supported as a `mixed` value. ([#6513](https://github.com/realm/realm-js/pull/6513)) +* A `mixed` value can now hold a `Realm.List` and `Realm.Dictionary` with nested collections. Note that `Realm.Set` is not supported as a `mixed` value. ([#6613](https://github.com/realm/realm-js/pull/6613)) ```typescript class CustomObject extends Realm.Object { value!: Realm.Types.Mixed; From e675112e951f22bd03a2cfc2b536d49c06395e36 Mon Sep 17 00:00:00 2001 From: LJ <81748770+elle-j@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:40:22 +0200 Subject: [PATCH 07/43] Add more links to Core issue. --- integration-tests/tests/src/tests/list.ts | 2 +- integration-tests/tests/src/tests/mixed.ts | 8 ++++---- packages/realm/src/Dictionary.ts | 2 +- packages/realm/src/List.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/integration-tests/tests/src/tests/list.ts b/integration-tests/tests/src/tests/list.ts index 64e9b536b5..d2b32e3306 100644 --- a/integration-tests/tests/src/tests/list.ts +++ b/integration-tests/tests/src/tests/list.ts @@ -794,7 +794,7 @@ describe("Lists", () => { obj.arrayCol = [this.realm.create<ITestObjectSchema>(TestObjectSchema.name, { doubleCol: 1.0 })]; expect(obj.arrayCol[0].doubleCol).equals(1.0); - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). // obj.arrayCol = obj.arrayCol; // eslint-disable-line no-self-assign // expect(obj.arrayCol[0].doubleCol).equals(1.0); diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 974bfda254..fe7f74264a 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -1417,7 +1417,7 @@ describe("Mixed", () => { expect(list[0]).equals("updated"); }); - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). it.skip("self assigns", function (this: RealmContext) { const created = this.realm.write(() => { return this.realm.create<IMixedSchema>(MixedSchema.name, { mixed: ["original1", "original2"] }); @@ -1439,7 +1439,7 @@ describe("Mixed", () => { expect(list[1]).equals("original2"); }); - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). it.skip("self assigns nested list", function (this: RealmContext) { const { mixed: list } = this.realm.write(() => { return this.realm.create<IMixedSchema>(MixedSchema.name, { @@ -1603,7 +1603,7 @@ describe("Mixed", () => { expect(dictionary.newKey).equals("updated"); }); - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). it.skip("self assigns", function (this: RealmContext) { const created = this.realm.write(() => { return this.realm.create<IMixedSchema>(MixedSchema.name, { @@ -1627,7 +1627,7 @@ describe("Mixed", () => { expect(dictionary.key2).equals("original2"); }); - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). it.skip("self assigns nested dictionary", function (this: RealmContext) { const { mixed: dictionary } = this.realm.write(() => { return this.realm.create<IMixedSchema>(MixedSchema.name, { diff --git a/packages/realm/src/Dictionary.ts b/packages/realm/src/Dictionary.ts index 9cc0eb181d..9bb10be828 100644 --- a/packages/realm/src/Dictionary.ts +++ b/packages/realm/src/Dictionary.ts @@ -421,7 +421,7 @@ export function insertIntoDictionaryOfMixed( internal: binding.Dictionary, toBinding: TypeHelpers["toBinding"], ) { - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Solve the "removeAll()" case for self-assignment (https://github.com/realm/realm-core/issues/7422). internal.removeAll(); for (const key in dictionary) { diff --git a/packages/realm/src/List.ts b/packages/realm/src/List.ts index 2e4564e8b8..0d1a00f7a0 100644 --- a/packages/realm/src/List.ts +++ b/packages/realm/src/List.ts @@ -426,7 +426,7 @@ export function insertIntoListOfMixed( internal: binding.List, toBinding: TypeHelpers["toBinding"], ) { - // TODO: Solve the "removeAll()" case for self-assignment. + // TODO: Solve the "removeAll()" case for self-assignment (https://github.com/realm/realm-core/issues/7422). internal.removeAll(); for (const [index, item] of list.entries()) { From ec9f30faae6cfeba7a003984824cb58eca86618b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= <kraen.hansen@mongodb.com> Date: Mon, 15 Apr 2024 14:22:45 +0200 Subject: [PATCH 08/43] Injecting Symbol_for (#6616) --- packages/realm/bindgen/src/templates/base-wrapper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/realm/bindgen/src/templates/base-wrapper.ts b/packages/realm/bindgen/src/templates/base-wrapper.ts index 607d0a4813..d50a8cdea1 100644 --- a/packages/realm/bindgen/src/templates/base-wrapper.ts +++ b/packages/realm/bindgen/src/templates/base-wrapper.ts @@ -84,6 +84,7 @@ export function generate({ spec: boundSpec }: TemplateContext, out: Outputter): "Decimal128", "EJSON_parse: EJSON.parse", "EJSON_stringify: EJSON.stringify", + "Symbol_for: Symbol.for", ]; for (const cls of spec.classes) { From 42f6814ee9a04d5b2b54de7a06948a9fa5bb679f Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:29:43 +0200 Subject: [PATCH 09/43] Basic synced collections in mixed tests --- .../tests/src/tests/sync/mixed.ts | 227 +++++++++++++----- 1 file changed, 173 insertions(+), 54 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 1e38a2c85a..068fa97ca2 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -16,21 +16,34 @@ // //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import Realm from "realm"; +import Realm, { Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; -type MixedClass = { - _id: Realm.BSON.ObjectId; - value: Realm.Mixed; - list: Realm.List<Realm.Mixed>; -}; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void | boolean; +class MixedClass extends Realm.Object<MixedClass> { + _id!: Realm.BSON.ObjectId; + value: Realm.Mixed; + list!: Realm.List<Realm.Mixed>; + dict!: Realm.Dictionary<Realm.Mixed>; + + static schema: ObjectSchema = { + name: "MixedClass", + properties: { + _id: "objectId", + value: "mixed", + list: "mixed[]", + dict: "mixed{}", + }, + primaryKey: "_id", + }; +} + /** * The default tester of values. * @param actual The value downloaded from the server. @@ -40,6 +53,52 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { expect(actual).equals(inserted); } +// These two are taken from the changelog. Should we add it +function expectList(value: unknown): asserts value is Realm.List { + // + if (!(value instanceof Realm.List)) { + throw new Error("Expected a 'Realm.List'."); + } +} +function expectDictionary(value: unknown): asserts value is Realm.Dictionary { + if (!(value instanceof Realm.Dictionary)) { + throw new Error("Expected a 'Realm.Dictionary'."); + } +} + +/** + * The default tester of values. + * @param actual The value downloaded from the server. + * @param inserted The value inserted locally before upload. + */ +function betterTester(actual: Realm.Mixed, inserted: Realm.Mixed) { + if (actual instanceof Realm.List) { + const insertedVal = inserted as Realm.Mixed[]; //TODO I should remove all of these "as" (?) + actual.forEach((item, index) => betterTester(item, insertedVal[index])); + } else if (actual instanceof Realm.Dictionary) { + Object.keys(actual).forEach((key) => betterTester(actual[key], inserted[key])); + } else if (actual instanceof Realm.BSON.Decimal128) { + const insertedVal = inserted as Realm.BSON.Decimal128; + expect(actual.bytes.equals(insertedVal.bytes)).equals(true); + } else if (actual instanceof Realm.BSON.ObjectID) { + const insertedVal = inserted as Realm.BSON.ObjectID; + expect(actual.equals(insertedVal)).equals(true); + } else if (actual instanceof Realm.BSON.UUID) { + const insertedVal = inserted as Realm.BSON.UUID; + expect(actual.equals(insertedVal)).equals(true); + } else if (actual instanceof Date) { + const insertedVal = inserted as Date; + expect(actual.getTime() == insertedVal.getTime()).equals(true); + } else if (actual instanceof ArrayBuffer) { + const insertedVal = inserted as Uint8Array; + const binary_view = new Uint8Array(actual); + expect(actual.byteLength).equals(insertedVal.byteLength); + binary_view.forEach((item, index) => betterTester(item, insertedVal[index])); + } else { + expect(actual).equals(inserted); + } +} + /** * Registers a test suite that: * - Opens a synced Realm @@ -48,35 +107,39 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { * - Deletes the Realm locally * - Reopens and downloads the Realm * - Performs a test to ensure the downloaded value match the value created locally. - * @param typeName - * @param options + * @param typeName Name of the mixed type (only used for the test name) + * @param value The value to be used for the test, or a function to obtain it + * @param testValue The function used to assert equality + * @param useFlexibleSync Boolean to indicate the use of flexible sync (otherwise partition based sync will be used) */ function describeRoundtrip({ typeName, value, - testValue = defaultTester, - flexibleSync, + valueTester = defaultTester, + useFlexibleSync, }: { typeName: string; value: Value; - testValue?: ValueTester; - flexibleSync: boolean; + valueTester?: ValueTester; + useFlexibleSync: boolean; }) { + valueTester = betterTester; function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { - const result = testValue(actual, inserted); + const result = valueTester(actual, inserted); if (typeof result === "boolean") { - expect(result).equals(true, `${testValue} failed!`); + //TODO If we use the default tester this is not necessary. + expect(result).equals(true, `${valueTester} failed!`); } } - // TODO: This might be a useful utility + // TODO: This might be a useful utility //Should we keep this around if not used? function log(...args: [string]) { const date = new Date(); console.log(date.toString(), date.getMilliseconds(), ...args); } async function setupTest(realm: Realm) { - if (flexibleSync) { + if (useFlexibleSync) { await realm.subscriptions.update((mutableSubs) => { mutableSubs.add(realm.objects("MixedClass")); }); @@ -86,22 +149,8 @@ function describeRoundtrip({ describe(`roundtrip of '${typeName}'`, () => { openRealmBefore({ - schema: [ - { - name: "MixedClass", - primaryKey: "_id", - properties: { - _id: "objectId", - value: "mixed?", - list: "mixed[]", - }, - }, - ], - sync: flexibleSync - ? { - flexible: true, - } - : { partitionValue: "mixed-test" }, + schema: [MixedClass], + sync: useFlexibleSync ? { flexible: true } : { partitionValue: "mixed-test" }, }); it("writes", async function (this: RealmContext) { @@ -110,7 +159,7 @@ function describeRoundtrip({ this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { this.value = typeof value === "function" ? value(this.realm) : value; - this.realm.create<MixedClass>("MixedClass", { + this.realm.create(MixedClass, { _id: this._id, value: this.value, // Adding a few other unrelated elements to the list @@ -138,7 +187,7 @@ function describeRoundtrip({ expect(typeof obj).equals("object"); // Test the single value performTest(obj.value, this.value); - // Test the list of values + // Test the list of values //TODO Maybe we don't need this? expect(obj.list.length).equals(4); const firstElement = obj.list[0]; performTest(firstElement, this.value); @@ -152,18 +201,18 @@ function describeRoundtrip({ function describeTypes(flexibleSync: boolean) { authenticateUserBefore(); - describeRoundtrip({ typeName: "null", value: null, flexibleSync }); + describeRoundtrip({ typeName: "null", value: null, useFlexibleSync: flexibleSync }); - // TODO: Provide an API to speficy storing this as an int - describeRoundtrip({ typeName: "int", value: 123, flexibleSync }); + // TODO: Provide an API to specify storing this as an int + describeRoundtrip({ typeName: "int", value: 123, useFlexibleSync: flexibleSync }); // TODO: Provide an API to specify which of these to store - describeRoundtrip({ typeName: "float / double", value: 123.456, flexibleSync }); + describeRoundtrip({ typeName: "float / double", value: 123.456, useFlexibleSync: flexibleSync }); - describeRoundtrip({ typeName: "bool (true)", value: true, flexibleSync }); - describeRoundtrip({ typeName: "bool (false)", value: false, flexibleSync }); + describeRoundtrip({ typeName: "bool (true)", value: true, useFlexibleSync: flexibleSync }); + describeRoundtrip({ typeName: "bool (false)", value: false, useFlexibleSync: flexibleSync }); - describeRoundtrip({ typeName: "string", value: "test-string", flexibleSync }); + describeRoundtrip({ typeName: "string", value: "test-string", useFlexibleSync: flexibleSync }); // Unsupported: // describeSimpleRoundtrip("undefined", undefined); @@ -172,43 +221,43 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "data", value: buffer, - testValue: (value: ArrayBuffer) => { + valueTester: (value: ArrayBuffer) => { expect(value.byteLength).equals(4); expect([...new Uint8Array(value)]).deep.equals([4, 8, 12, 16]); }, - flexibleSync, + useFlexibleSync: flexibleSync, }); const date = new Date(1620768552979); describeRoundtrip({ typeName: "date", value: date, - testValue: (value: Date) => value.getTime() === date.getTime(), - flexibleSync, + valueTester: (value: Date) => value.getTime() === date.getTime(), + useFlexibleSync: flexibleSync, }); const objectId = new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"); describeRoundtrip({ typeName: "ObjectId", value: objectId, - testValue: (value: Realm.BSON.ObjectId) => objectId.equals(value), - flexibleSync, + valueTester: (value: Realm.BSON.ObjectId) => objectId.equals(value), + useFlexibleSync: flexibleSync, }); const uuid = new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"); describeRoundtrip({ typeName: "UUID", value: uuid, - testValue: (value: Realm.BSON.UUID) => uuid.equals(value), - flexibleSync, + valueTester: (value: Realm.BSON.UUID) => uuid.equals(value), + useFlexibleSync: flexibleSync, }); const decimal128 = Realm.BSON.Decimal128.fromString("1234.5678"); describeRoundtrip({ typeName: "Decimal128", value: decimal128, - testValue: (value: Realm.BSON.Decimal128) => decimal128.bytes.equals(value.bytes), - flexibleSync, + valueTester: (value: Realm.BSON.Decimal128) => decimal128.bytes.equals(value.bytes), + useFlexibleSync: flexibleSync, }); const recursiveObjectId = new Realm.BSON.ObjectId(); @@ -224,12 +273,82 @@ function describeTypes(flexibleSync: boolean) { result.value = result; return result; }, - testValue: (value: MixedClass) => recursiveObjectId.equals(value._id), - flexibleSync, + valueTester: (value: MixedClass) => recursiveObjectId.equals(value._id), + useFlexibleSync: flexibleSync, }); + + if (flexibleSync) { + //TODO Remove only + describe("collections in mixed", () => { + const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]); + + const mixedList: Mixed[] = [ + null, + true, + 1, + 5.0, + "string", + Realm.BSON.Decimal128.fromString("1234.5678"), + new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + new Date(1620768552979), + data, + ]; + + const mixedDict = { + null: null, + bool: true, + int: 1, + float: 5.0, + string: "stringVal", + decimal: Realm.BSON.Decimal128.fromString("1234.5678"), + objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + date: new Date(1620768552979), + data: data, + }; + + const nestedMixedList: Mixed[] = [...mixedList, mixedList, mixedDict]; + + const nextedMixedDict = { + ...mixedDict, + innerList: mixedList, + innerDict: mixedDict, + }; + + describeRoundtrip({ + typeName: "list", + value: mixedList, + valueTester: betterTester, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "nested list", + value: nestedMixedList, + valueTester: betterTester, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "dictionary", + value: mixedDict, + valueTester: betterTester, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "nested dictionary", + value: nextedMixedDict, + valueTester: betterTester, + useFlexibleSync: true, + }); + }); + } } -describe("mixed", () => { +describe("mixed synced", () => { + //TODO Should we keep this around? describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); From 5a6b8c467ee9bcc6c605ae6eea83e88c048bab70 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:53:20 +0200 Subject: [PATCH 10/43] Removed previous method --- .../tests/src/tests/sync/mixed.ts | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 068fa97ca2..878edcb374 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -50,33 +50,12 @@ class MixedClass extends Realm.Object<MixedClass> { * @param inserted The value inserted locally before upload. */ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { - expect(actual).equals(inserted); -} - -// These two are taken from the changelog. Should we add it -function expectList(value: unknown): asserts value is Realm.List { - // - if (!(value instanceof Realm.List)) { - throw new Error("Expected a 'Realm.List'."); - } -} -function expectDictionary(value: unknown): asserts value is Realm.Dictionary { - if (!(value instanceof Realm.Dictionary)) { - throw new Error("Expected a 'Realm.Dictionary'."); - } -} - -/** - * The default tester of values. - * @param actual The value downloaded from the server. - * @param inserted The value inserted locally before upload. - */ -function betterTester(actual: Realm.Mixed, inserted: Realm.Mixed) { if (actual instanceof Realm.List) { const insertedVal = inserted as Realm.Mixed[]; //TODO I should remove all of these "as" (?) - actual.forEach((item, index) => betterTester(item, insertedVal[index])); + actual.forEach((item, index) => defaultTester(item, insertedVal[index])); } else if (actual instanceof Realm.Dictionary) { - Object.keys(actual).forEach((key) => betterTester(actual[key], inserted[key])); + const insertedVal = inserted as { [key: string]: Mixed }; + Object.keys(actual).forEach((key) => defaultTester(actual[key], insertedVal[key])); } else if (actual instanceof Realm.BSON.Decimal128) { const insertedVal = inserted as Realm.BSON.Decimal128; expect(actual.bytes.equals(insertedVal.bytes)).equals(true); @@ -90,10 +69,10 @@ function betterTester(actual: Realm.Mixed, inserted: Realm.Mixed) { const insertedVal = inserted as Date; expect(actual.getTime() == insertedVal.getTime()).equals(true); } else if (actual instanceof ArrayBuffer) { - const insertedVal = inserted as Uint8Array; - const binary_view = new Uint8Array(actual); - expect(actual.byteLength).equals(insertedVal.byteLength); - binary_view.forEach((item, index) => betterTester(item, insertedVal[index])); + const actualBinaryView = new Uint8Array(actual); + const insertedBynaryView = new Uint8Array(inserted as ArrayBuffer); + expect(actualBinaryView.byteLength).equals(insertedBynaryView.byteLength); + actualBinaryView.forEach((item, index) => defaultTester(item, insertedBynaryView[index])); } else { expect(actual).equals(inserted); } @@ -123,7 +102,6 @@ function describeRoundtrip({ valueTester?: ValueTester; useFlexibleSync: boolean; }) { - valueTester = betterTester; function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { const result = valueTester(actual, inserted); if (typeof result === "boolean") { @@ -278,7 +256,6 @@ function describeTypes(flexibleSync: boolean) { }); if (flexibleSync) { - //TODO Remove only describe("collections in mixed", () => { const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]); @@ -292,7 +269,7 @@ function describeTypes(flexibleSync: boolean) { new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), new Date(1620768552979), - data, + data.buffer, ]; const mixedDict = { @@ -305,7 +282,7 @@ function describeTypes(flexibleSync: boolean) { objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), date: new Date(1620768552979), - data: data, + data: data.buffer, }; const nestedMixedList: Mixed[] = [...mixedList, mixedList, mixedDict]; @@ -319,35 +296,31 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "list", value: mixedList, - valueTester: betterTester, useFlexibleSync: true, }); describeRoundtrip({ typeName: "nested list", value: nestedMixedList, - valueTester: betterTester, useFlexibleSync: true, }); describeRoundtrip({ typeName: "dictionary", value: mixedDict, - valueTester: betterTester, useFlexibleSync: true, }); describeRoundtrip({ typeName: "nested dictionary", value: nextedMixedDict, - valueTester: betterTester, useFlexibleSync: true, }); }); } } -describe("mixed synced", () => { +describe.only("mixed synced", () => { //TODO Should we keep this around? describe("partition-based sync roundtrip", function () { this.longTimeout(); From b960fd5da64d7be56d4de95229e629df4a1266b4 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:00:15 +0200 Subject: [PATCH 11/43] Various corrections --- .../tests/src/tests/sync/mixed.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 878edcb374..bc21c00668 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -24,7 +24,7 @@ import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); -type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void | boolean; +type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; class MixedClass extends Realm.Object<MixedClass> { _id!: Realm.BSON.ObjectId; @@ -103,11 +103,7 @@ function describeRoundtrip({ useFlexibleSync: boolean; }) { function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { - const result = valueTester(actual, inserted); - if (typeof result === "boolean") { - //TODO If we use the default tester this is not necessary. - expect(result).equals(true, `${valueTester} failed!`); - } + valueTester(actual, inserted); } // TODO: This might be a useful utility //Should we keep this around if not used? @@ -199,10 +195,6 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "data", value: buffer, - valueTester: (value: ArrayBuffer) => { - expect(value.byteLength).equals(4); - expect([...new Uint8Array(value)]).deep.equals([4, 8, 12, 16]); - }, useFlexibleSync: flexibleSync, }); @@ -210,7 +202,6 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "date", value: date, - valueTester: (value: Date) => value.getTime() === date.getTime(), useFlexibleSync: flexibleSync, }); @@ -218,7 +209,6 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "ObjectId", value: objectId, - valueTester: (value: Realm.BSON.ObjectId) => objectId.equals(value), useFlexibleSync: flexibleSync, }); @@ -226,7 +216,6 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "UUID", value: uuid, - valueTester: (value: Realm.BSON.UUID) => uuid.equals(value), useFlexibleSync: flexibleSync, }); @@ -234,7 +223,6 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "Decimal128", value: decimal128, - valueTester: (value: Realm.BSON.Decimal128) => decimal128.bytes.equals(value.bytes), useFlexibleSync: flexibleSync, }); @@ -251,7 +239,9 @@ function describeTypes(flexibleSync: boolean) { result.value = result; return result; }, - valueTester: (value: MixedClass) => recursiveObjectId.equals(value._id), + valueTester: (value: MixedClass) => { + expect(recursiveObjectId.equals(value._id)).equals(true); //TODO I should be able to put this into the default tester + }, useFlexibleSync: flexibleSync, }); From 3991becdb179c62b5eebd11ea8a213bb6443dfc1 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:02:43 +0200 Subject: [PATCH 12/43] Removed only --- integration-tests/tests/src/tests/sync/mixed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index bc21c00668..2d3cb41723 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -310,7 +310,7 @@ function describeTypes(flexibleSync: boolean) { } } -describe.only("mixed synced", () => { +describe("mixed synced", () => { //TODO Should we keep this around? describe("partition-based sync roundtrip", function () { this.longTimeout(); From 5502110c5f5774d03f09549f8f7c8940e63ad85d Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:38:14 +0200 Subject: [PATCH 13/43] Testing --- .../tests/src/hooks/import-app-before.ts | 6 +- .../tests/src/tests/sync/mixed.ts | 151 ++++++++++++------ integration-tests/tests/src/typings.d.ts | 6 +- 3 files changed, 110 insertions(+), 53 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index c3bad5017c..3dd7704466 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -16,7 +16,7 @@ // //////////////////////////////////////////////////////////////////////////// -import Realm from "realm"; +import Realm, { Credentials } from "realm"; import { AppConfig, AppImporter, Credentials } from "@realm/app-importer"; import { mongodbServiceType } from "../utils/ExtendedAppConfigBuilder"; @@ -148,6 +148,10 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); }); + + this.getUser = async (cred: Realm.Credentials): Promise<Realm.User> => { + return this.app.logIn(cred); + }; } }); diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 2d3cb41723..a7877b745c 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -16,12 +16,13 @@ // //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import Realm, { Mixed, ObjectSchema } from "realm"; +import Realm, { Credentials, Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; +import { OpenRealmConfiguration, openRealm } from "../../utils/open-realm"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; @@ -54,7 +55,7 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { const insertedVal = inserted as Realm.Mixed[]; //TODO I should remove all of these "as" (?) actual.forEach((item, index) => defaultTester(item, insertedVal[index])); } else if (actual instanceof Realm.Dictionary) { - const insertedVal = inserted as { [key: string]: Mixed }; + const insertedVal = inserted as { [key: string]: any }; Object.keys(actual).forEach((key) => defaultTester(actual[key], insertedVal[key])); } else if (actual instanceof Realm.BSON.Decimal128) { const insertedVal = inserted as Realm.BSON.Decimal128; @@ -78,6 +79,15 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { } } +async function setupTest(realm: Realm, useFlexibleSync: boolean) { + if (useFlexibleSync) { + await realm.subscriptions.update((mutableSubs) => { + mutableSubs.add(realm.objects("MixedClass")); + }); + await realm.subscriptions.waitForSynchronization(); + } +} + /** * Registers a test suite that: * - Opens a synced Realm @@ -106,21 +116,6 @@ function describeRoundtrip({ valueTester(actual, inserted); } - // TODO: This might be a useful utility //Should we keep this around if not used? - function log(...args: [string]) { - const date = new Date(); - console.log(date.toString(), date.getMilliseconds(), ...args); - } - - async function setupTest(realm: Realm) { - if (useFlexibleSync) { - await realm.subscriptions.update((mutableSubs) => { - mutableSubs.add(realm.objects("MixedClass")); - }); - await realm.subscriptions.waitForSynchronization(); - } - } - describe(`roundtrip of '${typeName}'`, () => { openRealmBefore({ schema: [MixedClass], @@ -128,8 +123,8 @@ function describeRoundtrip({ }); it("writes", async function (this: RealmContext) { - await setupTest(this.realm); - + await setupTest(this.realm, useFlexibleSync); + //TODO Maybe I could also check that the dictionary can change value this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { this.value = typeof value === "function" ? value(this.realm) : value; @@ -145,7 +140,7 @@ function describeRoundtrip({ itUploadsDeletesAndDownloads(); it("reads", async function (this: RealmContext) { - await setupTest(this.realm); + await setupTest(this.realm, useFlexibleSync); const obj = await new Promise<MixedClass>((resolve) => { this.realm @@ -161,7 +156,7 @@ function describeRoundtrip({ expect(typeof obj).equals("object"); // Test the single value performTest(obj.value, this.value); - // Test the list of values //TODO Maybe we don't need this? + // Test the list of values expect(obj.list.length).equals(4); const firstElement = obj.list[0]; performTest(firstElement, this.value); @@ -172,58 +167,53 @@ function describeRoundtrip({ }); } -function describeTypes(flexibleSync: boolean) { +function describeTypes(useFlexibleSync: boolean) { authenticateUserBefore(); - describeRoundtrip({ typeName: "null", value: null, useFlexibleSync: flexibleSync }); + describeRoundtrip({ typeName: "null", value: null, useFlexibleSync }); // TODO: Provide an API to specify storing this as an int - describeRoundtrip({ typeName: "int", value: 123, useFlexibleSync: flexibleSync }); + describeRoundtrip({ typeName: "int", value: 123, useFlexibleSync }); // TODO: Provide an API to specify which of these to store - describeRoundtrip({ typeName: "float / double", value: 123.456, useFlexibleSync: flexibleSync }); - - describeRoundtrip({ typeName: "bool (true)", value: true, useFlexibleSync: flexibleSync }); - describeRoundtrip({ typeName: "bool (false)", value: false, useFlexibleSync: flexibleSync }); - - describeRoundtrip({ typeName: "string", value: "test-string", useFlexibleSync: flexibleSync }); - - // Unsupported: - // describeSimpleRoundtrip("undefined", undefined); + describeRoundtrip({ typeName: "float / double", value: 123.456, useFlexibleSync }); + describeRoundtrip({ typeName: "bool (true)", value: true, useFlexibleSync }); + describeRoundtrip({ typeName: "bool (false)", value: false, useFlexibleSync }); + describeRoundtrip({ typeName: "string", value: "test-string", useFlexibleSync }); const buffer = new Uint8Array([4, 8, 12, 16]).buffer; describeRoundtrip({ typeName: "data", value: buffer, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); const date = new Date(1620768552979); describeRoundtrip({ typeName: "date", value: date, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); const objectId = new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"); describeRoundtrip({ typeName: "ObjectId", value: objectId, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); const uuid = new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"); describeRoundtrip({ typeName: "UUID", value: uuid, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); const decimal128 = Realm.BSON.Decimal128.fromString("1234.5678"); describeRoundtrip({ typeName: "Decimal128", value: decimal128, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); const recursiveObjectId = new Realm.BSON.ObjectId(); @@ -242,10 +232,10 @@ function describeTypes(flexibleSync: boolean) { valueTester: (value: MixedClass) => { expect(recursiveObjectId.equals(value._id)).equals(true); //TODO I should be able to put this into the default tester }, - useFlexibleSync: flexibleSync, + useFlexibleSync: useFlexibleSync, }); - if (flexibleSync) { + if (useFlexibleSync) { describe("collections in mixed", () => { const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]); @@ -277,7 +267,7 @@ function describeTypes(flexibleSync: boolean) { const nestedMixedList: Mixed[] = [...mixedList, mixedList, mixedDict]; - const nextedMixedDict = { + const nestedMixedDict = { ...mixedDict, innerList: mixedList, innerDict: mixedDict, @@ -303,24 +293,83 @@ function describeTypes(flexibleSync: boolean) { describeRoundtrip({ typeName: "nested dictionary", - value: nextedMixedDict, + value: nestedMixedDict, useFlexibleSync: true, }); }); } } -describe("mixed synced", () => { - //TODO Should we keep this around? - describe("partition-based sync roundtrip", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - describeTypes(false); - }); +describe.only("mixed synced", () => { + // describe("partition-based sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + // describeTypes(false); + // }); - describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + // describeTypes(true); + // }); + + describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - describeTypes(true); + + const realmConfig = { + schema: [MixedClass], + sync: { flexible: true }, + } satisfies OpenRealmConfiguration; + + it("writes", async function (this: Mocha.Context & AppContext & UserContext) { + const user1 = await this.getUser(Credentials.anonymous()); + const { realm: realm1, config: config1 } = await openRealm(realmConfig, user1); + await setupTest(realm1, true); // this adds the subscriptions + + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: 23, + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + + const user2 = await this.getUser(Credentials.anonymous()); + const { realm: realm2, config: config2 } = await openRealm(realmConfig, user2); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + expect(obj2.value).equals(obj1.value); + + //Cleanup + realm1.close(); + realm2.close(); + Realm.deleteFile(config1); + Realm.deleteFile(config2); + Realm.clearTestState(); + }); + + /*** + * What to test + * - changing from one type to another + * - string to list + * - modifying list + * - list to dictionary + * - modifying dictionary + * - + */ }); }); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index 5ff4744775..b8c45e3269 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -142,7 +142,11 @@ declare namespace Mocha { } // Mocha contexts made available by hooks -type AppContext = { app: Realm.App; databaseName: string } & Mocha.Context; +type AppContext = { + app: Realm.App; + databaseName: string; + getUser: (credentials: Realm.Credentials) => Promise<Realm.User>; +} & Mocha.Context; type UserContext = { user: Realm.User } & Mocha.Context; type CloseRealmOptions = { deleteFile: boolean; clearTestState: boolean; reopen: boolean }; type RealmContext = { From 6e4e18c5bac8791c444b5da8b03158c9bdeba5a0 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Fri, 19 Apr 2024 13:17:24 +0200 Subject: [PATCH 14/43] Corrected types --- integration-tests/tests/src/hooks/import-app-before.ts | 2 +- integration-tests/tests/src/hooks/open-realm-before.ts | 6 ++++++ integration-tests/tests/src/tests/sync/mixed.ts | 4 ++-- integration-tests/tests/src/typings.d.ts | 4 ++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index 3dd7704466..c7d52ce5bc 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -16,7 +16,7 @@ // //////////////////////////////////////////////////////////////////////////// -import Realm, { Credentials } from "realm"; +import Realm from "realm"; import { AppConfig, AppImporter, Credentials } from "@realm/app-importer"; import { mongodbServiceType } from "../utils/ExtendedAppConfigBuilder"; diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index 4bc9627db7..25b5b23770 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -83,3 +83,9 @@ export function openRealmBefore(config: OpenRealmConfiguration = {}): void { before(openRealmBefore.name, openRealmHook(config)); after("closeRealmAfter", closeThisRealm); } + +export function setupRealmHook() { + return async function openRealmHandler(this: UserContext & Mocha.Context): Promise<void> { + this.longTimeout(); + }; +} diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index a7877b745c..8cc7535fdc 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -323,7 +323,7 @@ describe.only("mixed synced", () => { } satisfies OpenRealmConfiguration; it("writes", async function (this: Mocha.Context & AppContext & UserContext) { - const user1 = await this.getUser(Credentials.anonymous()); + const user1 = await this.getUser(Credentials.anonymous(false)); const { realm: realm1, config: config1 } = await openRealm(realmConfig, user1); await setupTest(realm1, true); // this adds the subscriptions @@ -337,7 +337,7 @@ describe.only("mixed synced", () => { await realm1.syncSession?.uploadAllLocalChanges(); - const user2 = await this.getUser(Credentials.anonymous()); + const user2 = await this.getUser(Credentials.anonymous(false)); const { realm: realm2, config: config2 } = await openRealm(realmConfig, user2); await setupTest(realm2, true); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index b8c45e3269..605da209a7 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -157,6 +157,10 @@ type RealmContext = { */ closeRealm(options?: Partial<CloseRealmOptions>): Promise<void>; } & Mocha.Context; +type MultiRealmContext = { + realms: Realm[]; + getRealm: (config: Realm.OpenRealmBehaviorConfiguration) => Promise<Realm>; +} & Mocha.Context; type RealmObjectContext<T = Record<string, unknown>> = { object: Realm.Object<T> & T; } & RealmContext; From 889fcfe091cf577a409d2b98e231653e8d07f0d2 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:05:57 +0200 Subject: [PATCH 15/43] Various fixes --- .../tests/src/hooks/open-realm-before.ts | 36 ++++++++++-- .../tests/src/tests/sync/mixed.ts | 55 +++++++------------ integration-tests/tests/src/typings.d.ts | 5 +- 3 files changed, 55 insertions(+), 41 deletions(-) diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index 25b5b23770..a9e10cfd4a 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -69,9 +69,10 @@ export function openRealmHook(config: OpenRealmConfiguration = {}) { export function closeThisRealm(this: RealmContext & Mocha.Context): void { if (this.closeRealm) { this.closeRealm({ clearTestState: true, deleteFile: true }); + } else { + // Clearing the test state to ensure the sync session gets completely reset and nothing is cached between tests + Realm.clearTestState(); } - // Clearing the test state to ensure the sync session gets completely reset and nothing is cached between tests - Realm.clearTestState(); } export function openRealmBeforeEach(config: OpenRealmConfiguration = {}): void { @@ -84,8 +85,33 @@ export function openRealmBefore(config: OpenRealmConfiguration = {}): void { after("closeRealmAfter", closeThisRealm); } -export function setupRealmHook() { - return async function openRealmHandler(this: UserContext & Mocha.Context): Promise<void> { - this.longTimeout(); +export async function setupRealmHook(this: AppContext & MultiRealmContext): Promise<void> { + this.openedInfo = []; + + this.getRealm = async (config: OpenRealmConfiguration): Promise<Realm> => { + const user = await this.getUser(Realm.Credentials.anonymous(false)); + const realmAndConfig = await openRealm(config, user); + this.openedInfo.push(realmAndConfig); + return realmAndConfig.realm; + }; + + this.closeAllRealms = async () => { + this.openedInfo?.forEach(({ realm, config }) => { + if (!realm?.isClosed) { + realm.close(); + } + Realm.deleteFile(config); + }); + + Realm.clearTestState(); }; } + +export function closeMultiRealms(this: AppContext & MultiRealmContext): void { + this.closeAllRealms?.(); +} + +export function setupMultiRealmsBeforeAndAfterEach(): void { + beforeEach("openMultiRealmsBeforeEach", setupRealmHook); + afterEach("closeMultiRealmsAfterEach", closeMultiRealms); +} diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 8cc7535fdc..d588a51aaf 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -18,7 +18,12 @@ import { expect } from "chai"; import Realm, { Credentials, Mixed, ObjectSchema } from "realm"; -import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; +import { + importAppBefore, + authenticateUserBefore, + openRealmBefore, + setupMultiRealmsBeforeAndAfterEach as setupMultiRealmsBeforeAndAfterEach, +} from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; @@ -301,30 +306,30 @@ function describeTypes(useFlexibleSync: boolean) { } describe.only("mixed synced", () => { - // describe("partition-based sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - // describeTypes(false); - // }); - - // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - // describeTypes(true); - // }); + describe("partition-based sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + describeTypes(false); + }); + + describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + describeTypes(true); + }); describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + setupMultiRealmsBeforeAndAfterEach(); const realmConfig = { schema: [MixedClass], sync: { flexible: true }, } satisfies OpenRealmConfiguration; - it("writes", async function (this: Mocha.Context & AppContext & UserContext) { - const user1 = await this.getUser(Credentials.anonymous(false)); - const { realm: realm1, config: config1 } = await openRealm(realmConfig, user1); + it("simple test", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); // this adds the subscriptions const obId = new Realm.BSON.ObjectID(); @@ -337,8 +342,7 @@ describe.only("mixed synced", () => { await realm1.syncSession?.uploadAllLocalChanges(); - const user2 = await this.getUser(Credentials.anonymous(false)); - const { realm: realm2, config: config2 } = await openRealm(realmConfig, user2); + const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); const obj2 = await new Promise<MixedClass>((resolve) => { @@ -353,23 +357,6 @@ describe.only("mixed synced", () => { }); expect(obj2.value).equals(obj1.value); - - //Cleanup - realm1.close(); - realm2.close(); - Realm.deleteFile(config1); - Realm.deleteFile(config2); - Realm.clearTestState(); }); - - /*** - * What to test - * - changing from one type to another - * - string to list - * - modifying list - * - list to dictionary - * - modifying dictionary - * - - */ }); }); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index 605da209a7..861fc9f8d1 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -158,8 +158,9 @@ type RealmContext = { closeRealm(options?: Partial<CloseRealmOptions>): Promise<void>; } & Mocha.Context; type MultiRealmContext = { - realms: Realm[]; - getRealm: (config: Realm.OpenRealmBehaviorConfiguration) => Promise<Realm>; + openedInfo: { realm: Realm; config: Realm.Configuration }[]; + getRealm: (config: any) => Promise<Realm>; //any should be OpenRealmConfiguration + closeAllRealms(): Promise<void>; } & Mocha.Context; type RealmObjectContext<T = Record<string, unknown>> = { object: Realm.Object<T> & T; From 0b8ff4903f53478dd6b700ea7dc08706a02fad8c Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:27:30 +0200 Subject: [PATCH 16/43] Various fixes --- .../tests/src/tests/sync/mixed.ts | 265 ++++++++++++------ 1 file changed, 176 insertions(+), 89 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index d588a51aaf..a433899480 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -16,7 +16,7 @@ // //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import Realm, { Credentials, Mixed, ObjectSchema } from "realm"; +import Realm, { Mixed, ObjectSchema } from "realm"; import { importAppBefore, @@ -27,10 +27,9 @@ import { import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; -import { OpenRealmConfiguration, openRealm } from "../../utils/open-realm"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); -type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; +type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; class MixedClass extends Realm.Object<MixedClass> { _id!: Realm.BSON.ObjectId; @@ -55,13 +54,13 @@ class MixedClass extends Realm.Object<MixedClass> { * @param actual The value downloaded from the server. * @param inserted The value inserted locally before upload. */ -function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { +function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) { if (actual instanceof Realm.List) { - const insertedVal = inserted as Realm.Mixed[]; //TODO I should remove all of these "as" (?) - actual.forEach((item, index) => defaultTester(item, insertedVal[index])); + const insertedVal = inserted as Realm.Mixed[]; + actual.forEach((item, index) => defaultTester(item, insertedVal[index], realm)); } else if (actual instanceof Realm.Dictionary) { const insertedVal = inserted as { [key: string]: any }; - Object.keys(actual).forEach((key) => defaultTester(actual[key], insertedVal[key])); + Object.keys(actual).forEach((key) => defaultTester(actual[key], insertedVal[key], realm)); } else if (actual instanceof Realm.BSON.Decimal128) { const insertedVal = inserted as Realm.BSON.Decimal128; expect(actual.bytes.equals(insertedVal.bytes)).equals(true); @@ -79,6 +78,10 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { const insertedBynaryView = new Uint8Array(inserted as ArrayBuffer); expect(actualBinaryView.byteLength).equals(insertedBynaryView.byteLength); actualBinaryView.forEach((item, index) => defaultTester(item, insertedBynaryView[index])); + } else if (actual instanceof MixedClass && realm) { + const insertedVal = realm.objects<MixedClass>("MixedClass").filtered("_id = $0", actual._id)[0]; + expect(actual._id.equals(insertedVal._id)).equals(true); + defaultTester(actual.value, insertedVal.value); } else { expect(actual).equals(inserted); } @@ -117,8 +120,8 @@ function describeRoundtrip({ valueTester?: ValueTester; useFlexibleSync: boolean; }) { - function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { - valueTester(actual, inserted); + function performTest(actual: Realm.Mixed, inserted: Realm.Mixed, realm: Realm) { + valueTester(actual, inserted, realm); } describe(`roundtrip of '${typeName}'`, () => { @@ -160,11 +163,11 @@ function describeRoundtrip({ expect(typeof obj).equals("object"); // Test the single value - performTest(obj.value, this.value); + performTest(obj.value, this.value, this.realm); // Test the list of values expect(obj.list.length).equals(4); const firstElement = obj.list[0]; - performTest(firstElement, this.value); + performTest(firstElement, this.value, this.realm); // No need to keep these around delete this._id; delete this.value; @@ -242,63 +245,81 @@ function describeTypes(useFlexibleSync: boolean) { if (useFlexibleSync) { describe("collections in mixed", () => { - const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]); - - const mixedList: Mixed[] = [ - null, - true, - 1, - 5.0, - "string", - Realm.BSON.Decimal128.fromString("1234.5678"), - new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - new Date(1620768552979), - data.buffer, - ]; - - const mixedDict = { - null: null, - bool: true, - int: 1, - float: 5.0, - string: "stringVal", - decimal: Realm.BSON.Decimal128.fromString("1234.5678"), - objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - date: new Date(1620768552979), - data: data.buffer, + const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; + + const getMixedList = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return [ + null, + true, + 1, + 5.0, + "string", + Realm.BSON.Decimal128.fromString("1234.5678"), + new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + new Date(1620768552979), + data, + ob, + ]; + }; + + const getMixedDict = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return { + null: null, + bool: true, + int: 1, + float: 5.0, + string: "stringVal", + decimal: Realm.BSON.Decimal128.fromString("1234.5678"), + objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + date: new Date(1620768552979), + data: data, + obj: ob, + }; }; - const nestedMixedList: Mixed[] = [...mixedList, mixedList, mixedDict]; + const getNestedMixedList = (realm: Realm) => { + return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; + }; - const nestedMixedDict = { - ...mixedDict, - innerList: mixedList, - innerDict: mixedDict, + const getNestedMixedDict = (realm: Realm) => { + return { + ...getMixedDict(realm), + innerList: getMixedList(realm), + innerDict: getMixedDict(realm), + }; }; describeRoundtrip({ typeName: "list", - value: mixedList, + value: getMixedList, useFlexibleSync: true, }); describeRoundtrip({ typeName: "nested list", - value: nestedMixedList, + value: getNestedMixedList, useFlexibleSync: true, }); describeRoundtrip({ typeName: "dictionary", - value: mixedDict, + value: getMixedDict, useFlexibleSync: true, }); describeRoundtrip({ typeName: "nested dictionary", - value: nestedMixedDict, + value: getNestedMixedDict, useFlexibleSync: true, }); }); @@ -311,52 +332,118 @@ describe.only("mixed synced", () => { importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); describeTypes(false); }); - describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); describeTypes(true); }); - - describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - setupMultiRealmsBeforeAndAfterEach(); - - const realmConfig = { - schema: [MixedClass], - sync: { flexible: true }, - } satisfies OpenRealmConfiguration; - - it("simple test", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); // this adds the subscriptions - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: 23, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); - - expect(obj2.value).equals(obj1.value); - }); - }); + //TODO Need to add examples with objects + // describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + // setupMultiRealmsBeforeAndAfterEach(); + // const realmConfig = { + // schema: [MixedClass], + // sync: { flexible: true }, + // } satisfies OpenRealmConfiguration; + // function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise<void> { + // return new Promise((resolve) => { + // obj.addListener((_, changes) => { + // if (changes.changedProperties.includes(propertyName)) { + // obj.removeAllListeners(); + // resolve(); + // } + // }); + // }); + // } + // it("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + // const valuesToInsert = [ + // 1, + // "string", + // [22, "test"], + // { key1: 10, key2: new Date(1620768552979) }, + // new Realm.BSON.ObjectID(), + // ]; + // const realm1 = await this.getRealm(realmConfig); + // await setupTest(realm1, true); + // const obId = new Realm.BSON.ObjectID(); + // const obj1 = realm1.write(() => { + // return realm1.create(MixedClass, { + // _id: obId, + // }); + // }); + // await realm1.syncSession?.uploadAllLocalChanges(); + // const realm2 = await this.getRealm(realmConfig); + // await setupTest(realm2, true); + // const obj2 = await new Promise<MixedClass>((resolve) => { + // realm2 + // .objects<MixedClass>("MixedClass") + // .filtered("_id = $0", obId) + // .addListener(([obj]) => { + // if (obj) { + // resolve(obj); + // } + // }); + // }); + // expect(obj2.value).equals(obj1.value); + // for (const val of valuesToInsert) { + // console.log(val); + // realm1.write(() => { + // obj1.value = val; + // }); + // defaultTester(obj1.value, val); + // const waitPromise = getWaiter(obj2, "value"); + // await realm1.syncSession?.uploadAllLocalChanges(); + // await realm2.syncSession?.downloadAllServerChanges(); + // await waitPromise; + // defaultTester(obj2.value, val); + // } + // }); + // it.skip("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + // const valuesToInsert = [ + // 1, + // "string", + // [22, "test"], + // { key1: 10, key2: new Date(1620768552979) }, + // new Realm.BSON.ObjectID(), + // ]; + // const realm1 = await this.getRealm(realmConfig); + // await setupTest(realm1, true); + // const obId = new Realm.BSON.ObjectID(); + // const obj1 = realm1.write(() => { + // return realm1.create(MixedClass, { + // _id: obId, + // value: [], + // }); + // }); + // await realm1.syncSession?.uploadAllLocalChanges(); + // const realm2 = await this.getRealm(realmConfig); + // await setupTest(realm2, true); + // const obj2 = await new Promise<MixedClass>((resolve) => { + // realm2 + // .objects<MixedClass>("MixedClass") + // .filtered("_id = $0", obId) + // .addListener(([obj]) => { + // if (obj) { + // resolve(obj); + // } + // }); + // }); + // expect(obj2.value).equals(obj1.value); + // for (const val of valuesToInsert) { + // console.log(val); + // realm1.write(() => { + // obj1.value = val; + // }); + // defaultTester(obj1.value, val); + // await realm1.syncSession?.uploadAllLocalChanges(); + // await realm2.syncSession?.downloadAllServerChanges(); + // await realm1.syncSession?.uploadAllLocalChanges(); //TODO Need to find a way to wait for this reasonably + // await realm2.syncSession?.downloadAllServerChanges(); + // await realm1.syncSession?.uploadAllLocalChanges(); + // await realm2.syncSession?.downloadAllServerChanges(); + // defaultTester(obj2.value, val); + // } + // }); + // }); }); From f303fda9195178997f8c3ea31b45a57da0326c0b Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:49:50 +0200 Subject: [PATCH 17/43] Fixed equality [skip-ci] --- .../tests/src/tests/sync/mixed.ts | 334 +++++++++--------- 1 file changed, 171 insertions(+), 163 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index a433899480..25a3721f69 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -27,6 +27,7 @@ import { import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; +import { OpenRealmConfiguration } from "../../utils/open-realm"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; @@ -245,60 +246,6 @@ function describeTypes(useFlexibleSync: boolean) { if (useFlexibleSync) { describe("collections in mixed", () => { - const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; - - const getMixedList = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), - }); - - return [ - null, - true, - 1, - 5.0, - "string", - Realm.BSON.Decimal128.fromString("1234.5678"), - new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - new Date(1620768552979), - data, - ob, - ]; - }; - - const getMixedDict = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), - }); - - return { - null: null, - bool: true, - int: 1, - float: 5.0, - string: "stringVal", - decimal: Realm.BSON.Decimal128.fromString("1234.5678"), - objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - date: new Date(1620768552979), - data: data, - obj: ob, - }; - }; - - const getNestedMixedList = (realm: Realm) => { - return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; - }; - - const getNestedMixedDict = (realm: Realm) => { - return { - ...getMixedDict(realm), - innerList: getMixedList(realm), - innerDict: getMixedDict(realm), - }; - }; - describeRoundtrip({ typeName: "list", value: getMixedList, @@ -326,6 +273,60 @@ function describeTypes(useFlexibleSync: boolean) { } } +const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; + +const getMixedList = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return [ + null, + true, + 1, + 5.0, + "string", + Realm.BSON.Decimal128.fromString("1234.5678"), + new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + new Date(1620768552979), + data, + ob, + ]; +}; + +const getMixedDict = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return { + null: null, + bool: true, + int: 1, + float: 5.0, + string: "stringVal", + decimal: Realm.BSON.Decimal128.fromString("1234.5678"), + objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + date: new Date(1620768552979), + data: data, + obj: ob, + }; +}; + +const getNestedMixedList = (realm: Realm) => { + return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; +}; + +const getNestedMixedDict = (realm: Realm) => { + return { + ...getMixedDict(realm), + innerList: getMixedList(realm), + innerDict: getMixedDict(realm), + }; +}; + describe.only("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); @@ -337,113 +338,120 @@ describe.only("mixed synced", () => { importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); describeTypes(true); }); - //TODO Need to add examples with objects - // describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - // setupMultiRealmsBeforeAndAfterEach(); - // const realmConfig = { - // schema: [MixedClass], - // sync: { flexible: true }, - // } satisfies OpenRealmConfiguration; - // function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise<void> { - // return new Promise((resolve) => { - // obj.addListener((_, changes) => { - // if (changes.changedProperties.includes(propertyName)) { - // obj.removeAllListeners(); - // resolve(); - // } - // }); - // }); - // } - // it("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - // const valuesToInsert = [ - // 1, - // "string", - // [22, "test"], - // { key1: 10, key2: new Date(1620768552979) }, - // new Realm.BSON.ObjectID(), - // ]; - // const realm1 = await this.getRealm(realmConfig); - // await setupTest(realm1, true); - // const obId = new Realm.BSON.ObjectID(); - // const obj1 = realm1.write(() => { - // return realm1.create(MixedClass, { - // _id: obId, - // }); - // }); - // await realm1.syncSession?.uploadAllLocalChanges(); - // const realm2 = await this.getRealm(realmConfig); - // await setupTest(realm2, true); - // const obj2 = await new Promise<MixedClass>((resolve) => { - // realm2 - // .objects<MixedClass>("MixedClass") - // .filtered("_id = $0", obId) - // .addListener(([obj]) => { - // if (obj) { - // resolve(obj); - // } - // }); - // }); - // expect(obj2.value).equals(obj1.value); - // for (const val of valuesToInsert) { - // console.log(val); - // realm1.write(() => { - // obj1.value = val; - // }); - // defaultTester(obj1.value, val); - // const waitPromise = getWaiter(obj2, "value"); - // await realm1.syncSession?.uploadAllLocalChanges(); - // await realm2.syncSession?.downloadAllServerChanges(); - // await waitPromise; - // defaultTester(obj2.value, val); - // } - // }); - // it.skip("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - // const valuesToInsert = [ - // 1, - // "string", - // [22, "test"], - // { key1: 10, key2: new Date(1620768552979) }, - // new Realm.BSON.ObjectID(), - // ]; - // const realm1 = await this.getRealm(realmConfig); - // await setupTest(realm1, true); - // const obId = new Realm.BSON.ObjectID(); - // const obj1 = realm1.write(() => { - // return realm1.create(MixedClass, { - // _id: obId, - // value: [], - // }); - // }); - // await realm1.syncSession?.uploadAllLocalChanges(); - // const realm2 = await this.getRealm(realmConfig); - // await setupTest(realm2, true); - // const obj2 = await new Promise<MixedClass>((resolve) => { - // realm2 - // .objects<MixedClass>("MixedClass") - // .filtered("_id = $0", obId) - // .addListener(([obj]) => { - // if (obj) { - // resolve(obj); - // } - // }); - // }); - // expect(obj2.value).equals(obj1.value); - // for (const val of valuesToInsert) { - // console.log(val); - // realm1.write(() => { - // obj1.value = val; - // }); - // defaultTester(obj1.value, val); - // await realm1.syncSession?.uploadAllLocalChanges(); - // await realm2.syncSession?.downloadAllServerChanges(); - // await realm1.syncSession?.uploadAllLocalChanges(); //TODO Need to find a way to wait for this reasonably - // await realm2.syncSession?.downloadAllServerChanges(); - // await realm1.syncSession?.uploadAllLocalChanges(); - // await realm2.syncSession?.downloadAllServerChanges(); - // defaultTester(obj2.value, val); - // } - // }); - // }); + + describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + setupMultiRealmsBeforeAndAfterEach(); + + const realmConfig = { + schema: [MixedClass], + sync: { flexible: true }, + } satisfies OpenRealmConfiguration; + + function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise<void> { + return new Promise((resolve) => { + obj.addListener((_, changes) => { + if (changes.changedProperties.includes(propertyName)) { + obj.removeAllListeners(); + resolve(); + } + }); + }); + } + + it.only("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert = realm1.write(() => { + return getMixedList(realm1); + }); + + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + expect(obj2.value).equals(obj1.value); + + for (const val of valuesToInsert) { + realm1.write(() => { + obj1.value = val; + }); + + defaultTester(obj1.value, val, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, val, realm2); + } + }); + + it.skip("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const valuesToInsert = [ + 1, + "string", + [22, "test"], + { key1: 10, key2: new Date(1620768552979) }, + new Realm.BSON.ObjectID(), + ]; + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: [], + }); + }); + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + expect(obj2.value).equals(obj1.value); + for (const val of valuesToInsert) { + console.log(val); + realm1.write(() => { + obj1.value = val; + }); + defaultTester(obj1.value, val, realm2); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await realm1.syncSession?.uploadAllLocalChanges(); //TODO Need to find a way to wait for this reasonably + await realm2.syncSession?.downloadAllServerChanges(); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + defaultTester(obj2.value, val, realm2); + } + }); + }); }); From 883d26c0648b8799d2cd3cbd00bc690820018430 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 22 Apr 2024 13:11:51 +0200 Subject: [PATCH 18/43] Added list modification --- .../tests/src/tests/sync/mixed.ts | 178 ++++++++++++++++-- 1 file changed, 163 insertions(+), 15 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 25a3721f69..cf3bc9eeee 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -333,6 +333,7 @@ describe.only("mixed synced", () => { importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); describeTypes(false); }); + describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); @@ -360,12 +361,12 @@ describe.only("mixed synced", () => { }); } - it.only("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + it.skip("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); const valuesToInsert = realm1.write(() => { - return getMixedList(realm1); + return getNestedMixedList(realm1); }); const obId = new Realm.BSON.ObjectID(); @@ -407,16 +408,14 @@ describe.only("mixed synced", () => { } }); - it.skip("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const valuesToInsert = [ - 1, - "string", - [22, "test"], - { key1: 10, key2: new Date(1620768552979) }, - new Realm.BSON.ObjectID(), - ]; + it.only("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + const obId = new Realm.BSON.ObjectID(); const obj1 = realm1.write(() => { return realm1.create(MixedClass, { @@ -424,9 +423,11 @@ describe.only("mixed synced", () => { value: [], }); }); + await realm1.syncSession?.uploadAllLocalChanges(); const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); + const obj2 = await new Promise<MixedClass>((resolve) => { realm2 .objects<MixedClass>("MixedClass") @@ -437,20 +438,167 @@ describe.only("mixed synced", () => { } }); }); - expect(obj2.value).equals(obj1.value); + + //We will keep this list updated with the values we expect to find + const expectedList = []; + + //Adding elements one by one and verifying the list is synchronized + for (const val of valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.List).push(val); + }); + expectedList.push(val); + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + //Removing elements one by one and verifying the list is synchronized + for (let i = 0; i < valuesToInsert.length; i++) { + console.log("rem - " + i); + realm1.write(() => { + (obj1.value as Realm.List).pop(); + }); + expectedList.pop(); + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + expect((obj1.value as Realm.List).length).equals(0); + expect((obj2.value as Realm.List).length).equals(0); + + //Changing the first element and verifying the list is synchronized + + realm1.write(() => { + (obj1.value as Realm.List).push("test"); + }); + expectedList.push("test"); + for (const val of valuesToInsert) { console.log(val); realm1.write(() => { - obj1.value = val; + (obj1.value as Realm.List)[0] = val; }); - defaultTester(obj1.value, val, realm2); + expectedList[0] = val; + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); await realm1.syncSession?.uploadAllLocalChanges(); await realm2.syncSession?.downloadAllServerChanges(); - await realm1.syncSession?.uploadAllLocalChanges(); //TODO Need to find a way to wait for this reasonably + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + }); + + it.only("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: [], + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + //We will keep this list updated with the values we expect to find + const expectedList = []; + + //Adding elements one by one and verifying the list is synchronized + for (const val of valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.List).push(val); + }); + expectedList.push(val); + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + //Removing elements one by one and verifying the list is synchronized + for (let i = 0; i < valuesToInsert.length; i++) { + console.log("rem - " + i); + realm1.write(() => { + (obj1.value as Realm.List).pop(); + }); + expectedList.pop(); + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + expect((obj1.value as Realm.List).length).equals(0); + expect((obj2.value as Realm.List).length).equals(0); + + //Changing the first element and verifying the list is synchronized + + realm1.write(() => { + (obj1.value as Realm.List).push("test"); + }); + expectedList.push("test"); + + for (const val of valuesToInsert) { + console.log(val); + realm1.write(() => { + (obj1.value as Realm.List)[0] = val; + }); + expectedList[0] = val; + + defaultTester(obj1.value, expectedList, realm2); + + const waitPromise = getWaiter(obj2, "value"); await realm1.syncSession?.uploadAllLocalChanges(); await realm2.syncSession?.downloadAllServerChanges(); - defaultTester(obj2.value, val, realm2); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); } }); }); From 71c341c920f1516bdf5e4aab51f4bb94cd4bfe7b Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:29:34 +0200 Subject: [PATCH 19/43] Adding tests --- .../tests/src/hooks/open-realm-before.ts | 2 +- .../tests/src/tests/sync/mixed.ts | 160 ++++++++---------- integration-tests/tests/src/typings.d.ts | 2 +- 3 files changed, 75 insertions(+), 89 deletions(-) diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index a9e10cfd4a..623f917ef2 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -95,7 +95,7 @@ export async function setupRealmHook(this: AppContext & MultiRealmContext): Prom return realmAndConfig.realm; }; - this.closeAllRealms = async () => { + this.closeAllRealms = () => { this.openedInfo?.forEach(({ realm, config }) => { if (!realm?.isClosed) { realm.close(); diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index cf3bc9eeee..362f919fb4 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -28,6 +28,7 @@ import { import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; import { OpenRealmConfiguration } from "../../utils/open-realm"; +import { sleep } from "../../utils/sleep"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; @@ -91,7 +92,7 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm async function setupTest(realm: Realm, useFlexibleSync: boolean) { if (useFlexibleSync) { await realm.subscriptions.update((mutableSubs) => { - mutableSubs.add(realm.objects("MixedClass")); + mutableSubs.add(realm.objects(MixedClass)); }); await realm.subscriptions.waitForSynchronization(); } @@ -153,7 +154,7 @@ function describeRoundtrip({ const obj = await new Promise<MixedClass>((resolve) => { this.realm - .objects<MixedClass>("MixedClass") + .objects(MixedClass) //TODO Remember to do it in other places .filtered("_id = $0", this._id) .addListener(([obj]) => { if (obj) { @@ -328,17 +329,18 @@ const getNestedMixedDict = (realm: Realm) => { }; describe.only("mixed synced", () => { - describe("partition-based sync roundtrip", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - describeTypes(false); - }); - - describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - describeTypes(true); - }); + //TODO Reenable these + // describe("partition-based sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + // describeTypes(false); + // }); + + // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + // describeTypes(true); + // }); describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { this.longTimeout(); @@ -361,7 +363,7 @@ describe.only("mixed synced", () => { }); } - it.skip("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + it("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); @@ -369,7 +371,7 @@ describe.only("mixed synced", () => { return getNestedMixedList(realm1); }); - const obId = new Realm.BSON.ObjectID(); + const obId = new Realm.BSON.ObjectId(); const obj1 = realm1.write(() => { return realm1.create(MixedClass, { _id: obId, @@ -399,16 +401,21 @@ describe.only("mixed synced", () => { defaultTester(obj1.value, val, realm2); - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; + await delay(realm1, realm2); + //const waitPromise = getWaiter(obj2, "value"); + // await realm1.syncSession?.uploadAllLocalChanges(); + // await realm2.syncSession?.downloadAllServerChanges(); + // If I use sleep, then obj1 and obj2 become invalid after + // await sleep(500); + //await waitPromise; defaultTester(obj2.value, val, realm2); } + + obj2.removeAllListeners(); }); - it.only("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + it("list adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); @@ -459,53 +466,18 @@ describe.only("mixed synced", () => { defaultTester(obj2.value, expectedList, realm2); } - //Removing elements one by one and verifying the list is synchronized - for (let i = 0; i < valuesToInsert.length; i++) { - console.log("rem - " + i); - realm1.write(() => { - (obj1.value as Realm.List).pop(); - }); - expectedList.pop(); - - defaultTester(obj1.value, expectedList, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); - } - - expect((obj1.value as Realm.List).length).equals(0); - expect((obj2.value as Realm.List).length).equals(0); - - //Changing the first element and verifying the list is synchronized - - realm1.write(() => { - (obj1.value as Realm.List).push("test"); - }); - expectedList.push("test"); - - for (const val of valuesToInsert) { - console.log(val); - realm1.write(() => { - (obj1.value as Realm.List)[0] = val; - }); - expectedList[0] = val; - - defaultTester(obj1.value, expectedList, realm2); + obj2.removeAllListeners(); + }); - const waitPromise = getWaiter(obj2, "value"); + //TODO For testing, need to remove it afterwards + async function delay(realm1: Realm, realm2: Realm): Promise<void> { + for (let index = 0; index < 20; index++) { await realm1.syncSession?.uploadAllLocalChanges(); await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); } - }); + } - it.only("list modifications", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + it("list removing", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); @@ -517,7 +489,7 @@ describe.only("mixed synced", () => { const obj1 = realm1.write(() => { return realm1.create(MixedClass, { _id: obId, - value: [], + value: valuesToInsert, }); }); @@ -537,28 +509,10 @@ describe.only("mixed synced", () => { }); //We will keep this list updated with the values we expect to find - const expectedList = []; - - //Adding elements one by one and verifying the list is synchronized - for (const val of valuesToInsert) { - realm1.write(() => { - (obj1.value as Realm.List).push(val); - }); - expectedList.push(val); - - defaultTester(obj1.value, expectedList, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); - } + const expectedList = [...valuesToInsert]; //Removing elements one by one and verifying the list is synchronized for (let i = 0; i < valuesToInsert.length; i++) { - console.log("rem - " + i); realm1.write(() => { (obj1.value as Realm.List).pop(); }); @@ -577,15 +531,45 @@ describe.only("mixed synced", () => { expect((obj1.value as Realm.List).length).equals(0); expect((obj2.value as Realm.List).length).equals(0); - //Changing the first element and verifying the list is synchronized + obj2.removeAllListeners(); + }); - realm1.write(() => { - (obj1.value as Realm.List).push("test"); + it("list modification", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); }); - expectedList.push("test"); + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: ["test"], + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + //We will keep this list updated with the values we expect to find + const expectedList: Mixed[] = ["test"]; + + //Changing the first element and verifying the list is synchronized for (const val of valuesToInsert) { - console.log(val); realm1.write(() => { (obj1.value as Realm.List)[0] = val; }); @@ -600,6 +584,8 @@ describe.only("mixed synced", () => { defaultTester(obj2.value, expectedList, realm2); } + + obj2.removeAllListeners(); }); }); }); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index 861fc9f8d1..fe55c24ac5 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -160,7 +160,7 @@ type RealmContext = { type MultiRealmContext = { openedInfo: { realm: Realm; config: Realm.Configuration }[]; getRealm: (config: any) => Promise<Realm>; //any should be OpenRealmConfiguration - closeAllRealms(): Promise<void>; + closeAllRealms: () => void; } & Mocha.Context; type RealmObjectContext<T = Record<string, unknown>> = { object: Realm.Object<T> & T; From 3bf02b32f68e6ee25f374cc568d5f2da085ec65a Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Tue, 23 Apr 2024 10:50:59 +0200 Subject: [PATCH 20/43] Added dictionary tests --- .../tests/src/tests/sync/mixed.ts | 182 ++++++++++++++++-- 1 file changed, 163 insertions(+), 19 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 362f919fb4..3e2f08615d 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -401,18 +401,15 @@ describe.only("mixed synced", () => { defaultTester(obj1.value, val, realm2); - await delay(realm1, realm2); - //const waitPromise = getWaiter(obj2, "value"); - // await realm1.syncSession?.uploadAllLocalChanges(); - // await realm2.syncSession?.downloadAllServerChanges(); + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); // If I use sleep, then obj1 and obj2 become invalid after // await sleep(500); - //await waitPromise; + await waitPromise; defaultTester(obj2.value, val, realm2); } - - obj2.removeAllListeners(); }); it("list adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { @@ -465,18 +462,8 @@ describe.only("mixed synced", () => { defaultTester(obj2.value, expectedList, realm2); } - - obj2.removeAllListeners(); }); - //TODO For testing, need to remove it afterwards - async function delay(realm1: Realm, realm2: Realm): Promise<void> { - for (let index = 0; index < 20; index++) { - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - } - } - it("list removing", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); @@ -530,8 +517,6 @@ describe.only("mixed synced", () => { expect((obj1.value as Realm.List).length).equals(0); expect((obj2.value as Realm.List).length).equals(0); - - obj2.removeAllListeners(); }); it("list modification", async function (this: Mocha.Context & AppContext & MultiRealmContext) { @@ -587,5 +572,164 @@ describe.only("mixed synced", () => { obj2.removeAllListeners(); }); + + it.only("dictionary adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: {}, + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Adding elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + const val = valuesToInsert[key]; + realm1.write(() => { + (obj1.value as Realm.Dictionary)[key] = val; + }); + expectedDict[key] = val; + + defaultTester(obj1.value, expectedDict, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); + + it("dictionary removing", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: valuesToInsert, + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict = { ...valuesToInsert }; + + //Removing elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.Dictionary).remove(key); + }); + delete expectedDict[key]; + + defaultTester(obj1.value, expectedDict, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); + + it("dictionary modification", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + const realm1 = await this.getRealm(realmConfig); + await setupTest(realm1, true); + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const keyString = "keyString"; + const obId = new Realm.BSON.ObjectID(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: { [keyString]: 1 }, + }); + }); + + await realm1.syncSession?.uploadAllLocalChanges(); + const realm2 = await this.getRealm(realmConfig); + await setupTest(realm2, true); + + const obj2 = await new Promise<MixedClass>((resolve) => { + realm2 + .objects<MixedClass>("MixedClass") + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Modifying elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + const val = valuesToInsert[key]; + realm1.write(() => { + (obj1.value as Realm.Dictionary)[keyString] = val; + }); + expectedDict[keyString] = val; + + defaultTester(obj1.value, expectedDict, realm2); + + const waitPromise = getWaiter(obj2, "value"); + await realm1.syncSession?.uploadAllLocalChanges(); + await realm2.syncSession?.downloadAllServerChanges(); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); }); }); From 5808ee687dbf33316bc5525dd4df8bdaab47a7f8 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Tue, 23 Apr 2024 10:51:55 +0200 Subject: [PATCH 21/43] Reinstated tests --- .../tests/src/tests/sync/mixed.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 3e2f08615d..b5adf3d5e6 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -328,19 +328,18 @@ const getNestedMixedDict = (realm: Realm) => { }; }; -describe.only("mixed synced", () => { - //TODO Reenable these - // describe("partition-based sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - // describeTypes(false); - // }); - - // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - // describeTypes(true); - // }); +describe("mixed synced", () => { + describe("partition-based sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + describeTypes(false); + }); + + describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + describeTypes(true); + }); describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { this.longTimeout(); @@ -573,7 +572,7 @@ describe.only("mixed synced", () => { obj2.removeAllListeners(); }); - it.only("dictionary adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { + it("dictionary adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); From f30597039376c41824528940a474d28de922749b Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:06:48 +0200 Subject: [PATCH 22/43] Various improvements --- .../tests/src/hooks/open-realm-before.ts | 12 +-- .../tests/src/tests/sync/mixed.ts | 93 +++++-------------- 2 files changed, 27 insertions(+), 78 deletions(-) diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index 623f917ef2..6e797aa7db 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -96,12 +96,12 @@ export async function setupRealmHook(this: AppContext & MultiRealmContext): Prom }; this.closeAllRealms = () => { - this.openedInfo?.forEach(({ realm, config }) => { - if (!realm?.isClosed) { - realm.close(); - } - Realm.deleteFile(config); - }); + // this.openedInfo?.forEach(({ realm, config }) => { + // if (!realm?.isClosed) { + // realm.close(); + // } + // Realm.deleteFile(config); + // }); Realm.clearTestState(); }; diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index b5adf3d5e6..cc91cb662b 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -154,7 +154,7 @@ function describeRoundtrip({ const obj = await new Promise<MixedClass>((resolve) => { this.realm - .objects(MixedClass) //TODO Remember to do it in other places + .objects(MixedClass) .filtered("_id = $0", this._id) .addListener(([obj]) => { if (obj) { @@ -381,17 +381,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); - expect(obj2.value).equals(obj1.value); + const obj2 = await waitForMixedClassObj(realm2, obId); for (const val of valuesToInsert) { realm1.write(() => { @@ -431,16 +421,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this list updated with the values we expect to find const expectedList = []; @@ -483,16 +464,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this list updated with the values we expect to find const expectedList = [...valuesToInsert]; @@ -538,16 +510,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this list updated with the values we expect to find const expectedList: Mixed[] = ["test"]; @@ -592,16 +555,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this dictionary updated with the values we expect to find const expectedDict: { [key: string]: any } = {}; @@ -645,16 +599,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this dictionary updated with the values we expect to find const expectedDict = { ...valuesToInsert }; @@ -698,16 +643,7 @@ describe("mixed synced", () => { const realm2 = await this.getRealm(realmConfig); await setupTest(realm2, true); - const obj2 = await new Promise<MixedClass>((resolve) => { - realm2 - .objects<MixedClass>("MixedClass") - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); + const obj2 = await waitForMixedClassObj(realm2, obId); //We will keep this dictionary updated with the values we expect to find const expectedDict: { [key: string]: any } = {}; @@ -730,5 +666,18 @@ describe("mixed synced", () => { defaultTester(obj2.value, expectedDict, realm2); } }); + + function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise<MixedClass> { + return new Promise<MixedClass>((resolve) => { + realm + .objects(MixedClass) + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + } }); }); From 51bbdf1e3fa70400bde2a588cc85542ed96da15a Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 24 Apr 2024 10:43:45 +0200 Subject: [PATCH 23/43] Moved function up --- .../tests/src/tests/sync/mixed.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index cc91cb662b..d3a0c99d39 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -362,6 +362,19 @@ describe("mixed synced", () => { }); } + function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise<MixedClass> { + return new Promise<MixedClass>((resolve) => { + realm + .objects(MixedClass) + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + } + it("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { const realm1 = await this.getRealm(realmConfig); await setupTest(realm1, true); @@ -666,18 +679,5 @@ describe("mixed synced", () => { defaultTester(obj2.value, expectedDict, realm2); } }); - - function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise<MixedClass> { - return new Promise<MixedClass>((resolve) => { - realm - .objects(MixedClass) - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); - } }); }); From 46150ae21ccf6079d7fac0edba0aeb6892480e44 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 24 Apr 2024 14:10:53 +0200 Subject: [PATCH 24/43] Removed additional tests and modifications --- .../tests/src/hooks/import-app-before.ts | 4 - .../tests/src/hooks/open-realm-before.ts | 31 -- .../tests/src/tests/sync/mixed.ts | 461 +++--------------- integration-tests/tests/src/typings.d.ts | 5 - 4 files changed, 57 insertions(+), 444 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index c7d52ce5bc..c3bad5017c 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -148,10 +148,6 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); }); - - this.getUser = async (cred: Realm.Credentials): Promise<Realm.User> => { - return this.app.logIn(cred); - }; } }); diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index 6e797aa7db..3e91067490 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -84,34 +84,3 @@ export function openRealmBefore(config: OpenRealmConfiguration = {}): void { before(openRealmBefore.name, openRealmHook(config)); after("closeRealmAfter", closeThisRealm); } - -export async function setupRealmHook(this: AppContext & MultiRealmContext): Promise<void> { - this.openedInfo = []; - - this.getRealm = async (config: OpenRealmConfiguration): Promise<Realm> => { - const user = await this.getUser(Realm.Credentials.anonymous(false)); - const realmAndConfig = await openRealm(config, user); - this.openedInfo.push(realmAndConfig); - return realmAndConfig.realm; - }; - - this.closeAllRealms = () => { - // this.openedInfo?.forEach(({ realm, config }) => { - // if (!realm?.isClosed) { - // realm.close(); - // } - // Realm.deleteFile(config); - // }); - - Realm.clearTestState(); - }; -} - -export function closeMultiRealms(this: AppContext & MultiRealmContext): void { - this.closeAllRealms?.(); -} - -export function setupMultiRealmsBeforeAndAfterEach(): void { - beforeEach("openMultiRealmsBeforeEach", setupRealmHook); - afterEach("closeMultiRealmsAfterEach", closeMultiRealms); -} diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index d3a0c99d39..74af922466 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -18,17 +18,10 @@ import { expect } from "chai"; import Realm, { Mixed, ObjectSchema } from "realm"; -import { - importAppBefore, - authenticateUserBefore, - openRealmBefore, - setupMultiRealmsBeforeAndAfterEach as setupMultiRealmsBeforeAndAfterEach, -} from "../../hooks"; +import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; -import { OpenRealmConfiguration } from "../../utils/open-realm"; -import { sleep } from "../../utils/sleep"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; @@ -51,6 +44,60 @@ class MixedClass extends Realm.Object<MixedClass> { }; } +const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; + +const getMixedList = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return [ + null, + true, + 1, + 5.0, + "string", + Realm.BSON.Decimal128.fromString("1234.5678"), + new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + new Date(1620768552979), + data, + ob, + ]; +}; + +const getMixedDict = (realm: Realm) => { + const ob = realm.create<MixedClass>("MixedClass", { + _id: new Realm.BSON.ObjectId(), + }); + + return { + null: null, + bool: true, + int: 1, + float: 5.0, + string: "stringVal", + decimal: Realm.BSON.Decimal128.fromString("1234.5678"), + objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), + uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), + date: new Date(1620768552979), + data: data, + obj: ob, + }; +}; + +const getNestedMixedList = (realm: Realm) => { + return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; +}; + +const getNestedMixedDict = (realm: Realm) => { + return { + ...getMixedDict(realm), + innerList: getMixedList(realm), + innerDict: getMixedDict(realm), + }; +}; + /** * The default tester of values. * @param actual The value downloaded from the server. @@ -240,7 +287,7 @@ function describeTypes(useFlexibleSync: boolean) { return result; }, valueTester: (value: MixedClass) => { - expect(recursiveObjectId.equals(value._id)).equals(true); //TODO I should be able to put this into the default tester + expect(recursiveObjectId.equals(value._id)).equals(true); }, useFlexibleSync: useFlexibleSync, }); @@ -274,61 +321,7 @@ function describeTypes(useFlexibleSync: boolean) { } } -const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; - -const getMixedList = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), - }); - - return [ - null, - true, - 1, - 5.0, - "string", - Realm.BSON.Decimal128.fromString("1234.5678"), - new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - new Date(1620768552979), - data, - ob, - ]; -}; - -const getMixedDict = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), - }); - - return { - null: null, - bool: true, - int: 1, - float: 5.0, - string: "stringVal", - decimal: Realm.BSON.Decimal128.fromString("1234.5678"), - objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - date: new Date(1620768552979), - data: data, - obj: ob, - }; -}; - -const getNestedMixedList = (realm: Realm) => { - return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; -}; - -const getNestedMixedDict = (realm: Realm) => { - return { - ...getMixedDict(realm), - innerList: getMixedList(realm), - innerDict: getMixedDict(realm), - }; -}; - -describe("mixed synced", () => { +describe.only("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); @@ -340,344 +333,4 @@ describe("mixed synced", () => { importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); describeTypes(true); }); - - describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - setupMultiRealmsBeforeAndAfterEach(); - - const realmConfig = { - schema: [MixedClass], - sync: { flexible: true }, - } satisfies OpenRealmConfiguration; - - function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise<void> { - return new Promise((resolve) => { - obj.addListener((_, changes) => { - if (changes.changedProperties.includes(propertyName)) { - obj.removeAllListeners(); - resolve(); - } - }); - }); - } - - function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise<MixedClass> { - return new Promise<MixedClass>((resolve) => { - realm - .objects(MixedClass) - .filtered("_id = $0", obId) - .addListener(([obj]) => { - if (obj) { - resolve(obj); - } - }); - }); - } - - it("value change", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert = realm1.write(() => { - return getNestedMixedList(realm1); - }); - - const obId = new Realm.BSON.ObjectId(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - for (const val of valuesToInsert) { - realm1.write(() => { - obj1.value = val; - }); - - defaultTester(obj1.value, val, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - // If I use sleep, then obj1 and obj2 become invalid after - // await sleep(500); - await waitPromise; - - defaultTester(obj2.value, val, realm2); - } - }); - - it("list adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert = realm1.write(() => { - return getNestedMixedList(realm1); - }); - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: [], - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this list updated with the values we expect to find - const expectedList = []; - - //Adding elements one by one and verifying the list is synchronized - for (const val of valuesToInsert) { - realm1.write(() => { - (obj1.value as Realm.List).push(val); - }); - expectedList.push(val); - - defaultTester(obj1.value, expectedList, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); - } - }); - - it("list removing", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert = realm1.write(() => { - return getNestedMixedList(realm1); - }); - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: valuesToInsert, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this list updated with the values we expect to find - const expectedList = [...valuesToInsert]; - - //Removing elements one by one and verifying the list is synchronized - for (let i = 0; i < valuesToInsert.length; i++) { - realm1.write(() => { - (obj1.value as Realm.List).pop(); - }); - expectedList.pop(); - - defaultTester(obj1.value, expectedList, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); - } - - expect((obj1.value as Realm.List).length).equals(0); - expect((obj2.value as Realm.List).length).equals(0); - }); - - it("list modification", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert = realm1.write(() => { - return getNestedMixedList(realm1); - }); - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: ["test"], - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this list updated with the values we expect to find - const expectedList: Mixed[] = ["test"]; - - //Changing the first element and verifying the list is synchronized - for (const val of valuesToInsert) { - realm1.write(() => { - (obj1.value as Realm.List)[0] = val; - }); - expectedList[0] = val; - - defaultTester(obj1.value, expectedList, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedList, realm2); - } - - obj2.removeAllListeners(); - }); - - it("dictionary adding", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert: { [key: string]: any } = realm1.write(() => { - return getNestedMixedDict(realm1); - }); - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: {}, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this dictionary updated with the values we expect to find - const expectedDict: { [key: string]: any } = {}; - - //Adding elements one by one and verifying the dictionary is synchronized - for (const key in valuesToInsert) { - const val = valuesToInsert[key]; - realm1.write(() => { - (obj1.value as Realm.Dictionary)[key] = val; - }); - expectedDict[key] = val; - - defaultTester(obj1.value, expectedDict, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedDict, realm2); - } - }); - - it("dictionary removing", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert: { [key: string]: any } = realm1.write(() => { - return getNestedMixedDict(realm1); - }); - - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: valuesToInsert, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this dictionary updated with the values we expect to find - const expectedDict = { ...valuesToInsert }; - - //Removing elements one by one and verifying the dictionary is synchronized - for (const key in valuesToInsert) { - realm1.write(() => { - (obj1.value as Realm.Dictionary).remove(key); - }); - delete expectedDict[key]; - - defaultTester(obj1.value, expectedDict, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedDict, realm2); - } - }); - - it("dictionary modification", async function (this: Mocha.Context & AppContext & MultiRealmContext) { - const realm1 = await this.getRealm(realmConfig); - await setupTest(realm1, true); - - const valuesToInsert: { [key: string]: any } = realm1.write(() => { - return getNestedMixedDict(realm1); - }); - - const keyString = "keyString"; - const obId = new Realm.BSON.ObjectID(); - const obj1 = realm1.write(() => { - return realm1.create(MixedClass, { - _id: obId, - value: { [keyString]: 1 }, - }); - }); - - await realm1.syncSession?.uploadAllLocalChanges(); - const realm2 = await this.getRealm(realmConfig); - await setupTest(realm2, true); - - const obj2 = await waitForMixedClassObj(realm2, obId); - - //We will keep this dictionary updated with the values we expect to find - const expectedDict: { [key: string]: any } = {}; - - //Modifying elements one by one and verifying the dictionary is synchronized - for (const key in valuesToInsert) { - const val = valuesToInsert[key]; - realm1.write(() => { - (obj1.value as Realm.Dictionary)[keyString] = val; - }); - expectedDict[keyString] = val; - - defaultTester(obj1.value, expectedDict, realm2); - - const waitPromise = getWaiter(obj2, "value"); - await realm1.syncSession?.uploadAllLocalChanges(); - await realm2.syncSession?.downloadAllServerChanges(); - await waitPromise; - - defaultTester(obj2.value, expectedDict, realm2); - } - }); - }); }); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index fe55c24ac5..b8c45e3269 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -157,11 +157,6 @@ type RealmContext = { */ closeRealm(options?: Partial<CloseRealmOptions>): Promise<void>; } & Mocha.Context; -type MultiRealmContext = { - openedInfo: { realm: Realm; config: Realm.Configuration }[]; - getRealm: (config: any) => Promise<Realm>; //any should be OpenRealmConfiguration - closeAllRealms: () => void; -} & Mocha.Context; type RealmObjectContext<T = Record<string, unknown>> = { object: Realm.Object<T> & T; } & RealmContext; From ab512bb7929de7929502aba0ec75b402fc65a6a8 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 24 Apr 2024 14:12:24 +0200 Subject: [PATCH 25/43] Small fixes --- integration-tests/tests/src/tests/sync/mixed.ts | 2 +- integration-tests/tests/src/typings.d.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 74af922466..e4528843e0 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -321,7 +321,7 @@ function describeTypes(useFlexibleSync: boolean) { } } -describe.only("mixed synced", () => { +describe("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index b8c45e3269..5ff4744775 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -142,11 +142,7 @@ declare namespace Mocha { } // Mocha contexts made available by hooks -type AppContext = { - app: Realm.App; - databaseName: string; - getUser: (credentials: Realm.Credentials) => Promise<Realm.User>; -} & Mocha.Context; +type AppContext = { app: Realm.App; databaseName: string } & Mocha.Context; type UserContext = { user: Realm.User } & Mocha.Context; type CloseRealmOptions = { deleteFile: boolean; clearTestState: boolean; reopen: boolean }; type RealmContext = { From d2c1a4ed7c475fe8dd4c422c03367fe3749d0531 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 24 Apr 2024 14:13:23 +0200 Subject: [PATCH 26/43] Removed todo --- integration-tests/tests/src/tests/sync/mixed.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index e4528843e0..be7d9e9210 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -181,7 +181,6 @@ function describeRoundtrip({ it("writes", async function (this: RealmContext) { await setupTest(this.realm, useFlexibleSync); - //TODO Maybe I could also check that the dictionary can change value this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { this.value = typeof value === "function" ? value(this.realm) : value; From 105054daa9f0d9be538c8eda5890d8cbca003190 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 24 Apr 2024 14:18:55 +0200 Subject: [PATCH 27/43] Removed unused --- integration-tests/tests/src/tests/sync/mixed.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index be7d9e9210..89ae9b628d 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -30,7 +30,6 @@ class MixedClass extends Realm.Object<MixedClass> { _id!: Realm.BSON.ObjectId; value: Realm.Mixed; list!: Realm.List<Realm.Mixed>; - dict!: Realm.Dictionary<Realm.Mixed>; static schema: ObjectSchema = { name: "MixedClass", @@ -38,7 +37,6 @@ class MixedClass extends Realm.Object<MixedClass> { _id: "objectId", value: "mixed", list: "mixed[]", - dict: "mixed{}", }, primaryKey: "_id", }; From ba5ec500eadb42df34bec3ccb9071a022c1c19ab Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:48:42 +0200 Subject: [PATCH 28/43] Apply suggestions from code review Co-authored-by: LJ <81748770+elle-j@users.noreply.github.com> --- .../tests/src/tests/sync/mixed.ts | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 89ae9b628d..e09d95cf91 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -15,11 +15,11 @@ // limitations under the License. // //////////////////////////////////////////////////////////////////////////// + import { expect } from "chai"; import Realm, { Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; - import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; @@ -42,95 +42,95 @@ class MixedClass extends Realm.Object<MixedClass> { }; } +const bool = true; +const int = 1; +const double = 123.456; +const d128 = BSON.Decimal128.fromString("6.022e23"); +const string = "hello"; +const date = new Date(); +const oid = new BSON.ObjectId(); +const uuid = new BSON.UUID(); +const nullValue = null; const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; -const getMixedList = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), +function getMixedList(realm: Realm) { + const obj = realm.write(() => { + return realm.create(MixedClass, { _id: new BSON.ObjectId() }); }); - return [ - null, - true, - 1, - 5.0, - "string", - Realm.BSON.Decimal128.fromString("1234.5678"), - new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - new Date(1620768552979), - data, - ob, - ]; -}; + return [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; +} -const getMixedDict = (realm: Realm) => { - const ob = realm.create<MixedClass>("MixedClass", { - _id: new Realm.BSON.ObjectId(), +function getMixedDict(realm: Realm) { + const obj = realm.write(() => { + return realm.create(MixedClass, { _id: new BSON.ObjectId() }); }); return { - null: null, - bool: true, - int: 1, - float: 5.0, - string: "stringVal", - decimal: Realm.BSON.Decimal128.fromString("1234.5678"), - objectId: new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"), - uuid: new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"), - date: new Date(1620768552979), - data: data, - obj: ob, + bool, + int, + double, + d128, + string, + oid, + uuid, + nullValue, + date, + data, + obj, }; -}; +} -const getNestedMixedList = (realm: Realm) => { +function getNestedMixedList(realm: Realm) { return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; -}; +} -const getNestedMixedDict = (realm: Realm) => { +function getNestedMixedDict(realm: Realm) { return { ...getMixedDict(realm), innerList: getMixedList(realm), innerDict: getMixedDict(realm), }; -}; +} + +function expectJsArray(value: unknown): asserts value is unknown[] { + expect(value).to.be.an("array"); +} + +function expectJsObject(value: unknown): asserts value is Record<string, unknown> { + expect(value).to.be.an("object"); +} /** * The default tester of values. * @param actual The value downloaded from the server. * @param inserted The value inserted locally before upload. */ -function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) { +function defaultTester(actual: unknown, inserted: unknown) { if (actual instanceof Realm.List) { - const insertedVal = inserted as Realm.Mixed[]; - actual.forEach((item, index) => defaultTester(item, insertedVal[index], realm)); + expectJsArray(inserted); + expect(actual.length).equals(inserted.length); + actual.forEach((item, index) => defaultTester(item, inserted[index])); } else if (actual instanceof Realm.Dictionary) { - const insertedVal = inserted as { [key: string]: any }; - Object.keys(actual).forEach((key) => defaultTester(actual[key], insertedVal[key], realm)); - } else if (actual instanceof Realm.BSON.Decimal128) { - const insertedVal = inserted as Realm.BSON.Decimal128; - expect(actual.bytes.equals(insertedVal.bytes)).equals(true); - } else if (actual instanceof Realm.BSON.ObjectID) { - const insertedVal = inserted as Realm.BSON.ObjectID; - expect(actual.equals(insertedVal)).equals(true); - } else if (actual instanceof Realm.BSON.UUID) { - const insertedVal = inserted as Realm.BSON.UUID; - expect(actual.equals(insertedVal)).equals(true); - } else if (actual instanceof Date) { - const insertedVal = inserted as Date; - expect(actual.getTime() == insertedVal.getTime()).equals(true); + expectJsObject(inserted); + const actualKeys = Object.keys(actual); + expect(actualKeys).members(Object.keys(inserted)); + actualKeys.forEach((key) => defaultTester(actual[key], inserted[key])); } else if (actual instanceof ArrayBuffer) { const actualBinaryView = new Uint8Array(actual); - const insertedBynaryView = new Uint8Array(inserted as ArrayBuffer); - expect(actualBinaryView.byteLength).equals(insertedBynaryView.byteLength); - actualBinaryView.forEach((item, index) => defaultTester(item, insertedBynaryView[index])); - } else if (actual instanceof MixedClass && realm) { - const insertedVal = realm.objects<MixedClass>("MixedClass").filtered("_id = $0", actual._id)[0]; - expect(actual._id.equals(insertedVal._id)).equals(true); - defaultTester(actual.value, insertedVal.value); + const insertedBinaryView = new Uint8Array(inserted as ArrayBuffer); + expect(actualBinaryView.byteLength).equals(insertedBinaryView.byteLength); + actualBinaryView.forEach((item, index) => defaultTester(item, insertedBinaryView[index])); + } else if (actual instanceof Realm.Object) { + expect(actual).instanceOf(MixedClass); + expect(inserted).instanceOf(MixedClass); + // If-block is set up only for TS to infer the correct types. + // if (actual instanceof MixedClass && inserted instanceof MixedClass) { + // expect(actual._id.equals(inserted._id)).to.be.true; + // defaultTester(actual.value, inserted.value); + // } } else { - expect(actual).equals(inserted); + expect(String(actual)).equals(String(inserted)); } } @@ -153,8 +153,8 @@ async function setupTest(realm: Realm, useFlexibleSync: boolean) { * - Performs a test to ensure the downloaded value match the value created locally. * @param typeName Name of the mixed type (only used for the test name) * @param value The value to be used for the test, or a function to obtain it - * @param testValue The function used to assert equality - * @param useFlexibleSync Boolean to indicate the use of flexible sync (otherwise partition based sync will be used) + * @param valueTester The function used to assert equality + * @param useFlexibleSync Whether to use flexible sync (otherwise partition based sync will be used) */ function describeRoundtrip({ typeName, @@ -239,35 +239,35 @@ function describeTypes(useFlexibleSync: boolean) { describeRoundtrip({ typeName: "data", value: buffer, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); const date = new Date(1620768552979); describeRoundtrip({ typeName: "date", value: date, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); const objectId = new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"); describeRoundtrip({ typeName: "ObjectId", value: objectId, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); const uuid = new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"); describeRoundtrip({ typeName: "UUID", value: uuid, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); const decimal128 = Realm.BSON.Decimal128.fromString("1234.5678"); describeRoundtrip({ typeName: "Decimal128", value: decimal128, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); const recursiveObjectId = new Realm.BSON.ObjectId(); @@ -284,9 +284,9 @@ function describeTypes(useFlexibleSync: boolean) { return result; }, valueTester: (value: MixedClass) => { - expect(recursiveObjectId.equals(value._id)).equals(true); + expect(recursiveObjectId.equals(value._id)).to.be.true; }, - useFlexibleSync: useFlexibleSync, + useFlexibleSync, }); if (useFlexibleSync) { From b9c747081fa1ca58b3fc686932652ff551fe2086 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:57:08 +0200 Subject: [PATCH 29/43] Small corrections --- .../tests/src/tests/sync/mixed.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index e09d95cf91..04c9586f3b 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -17,7 +17,7 @@ //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import Realm, { Mixed, ObjectSchema } from "realm"; +import Realm, { BSON, Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; @@ -54,17 +54,13 @@ const nullValue = null; const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; function getMixedList(realm: Realm) { - const obj = realm.write(() => { - return realm.create(MixedClass, { _id: new BSON.ObjectId() }); - }); + const obj = realm.create(MixedClass, { _id: new BSON.ObjectId() }); return [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; } function getMixedDict(realm: Realm) { - const obj = realm.write(() => { - return realm.create(MixedClass, { _id: new BSON.ObjectId() }); - }); + const obj = realm.create(MixedClass, { _id: new BSON.ObjectId() }); return { bool, @@ -124,17 +120,12 @@ function defaultTester(actual: unknown, inserted: unknown) { } else if (actual instanceof Realm.Object) { expect(actual).instanceOf(MixedClass); expect(inserted).instanceOf(MixedClass); - // If-block is set up only for TS to infer the correct types. - // if (actual instanceof MixedClass && inserted instanceof MixedClass) { - // expect(actual._id.equals(inserted._id)).to.be.true; - // defaultTester(actual.value, inserted.value); - // } } else { expect(String(actual)).equals(String(inserted)); } } -async function setupTest(realm: Realm, useFlexibleSync: boolean) { +async function setupIfFlexiblySync(realm: Realm, useFlexibleSync: boolean) { if (useFlexibleSync) { await realm.subscriptions.update((mutableSubs) => { mutableSubs.add(realm.objects(MixedClass)); @@ -178,7 +169,7 @@ function describeRoundtrip({ }); it("writes", async function (this: RealmContext) { - await setupTest(this.realm, useFlexibleSync); + await setupIfFlexiblySync(this.realm, useFlexibleSync); this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { this.value = typeof value === "function" ? value(this.realm) : value; @@ -194,7 +185,7 @@ function describeRoundtrip({ itUploadsDeletesAndDownloads(); it("reads", async function (this: RealmContext) { - await setupTest(this.realm, useFlexibleSync); + await setupIfFlexiblySync(this.realm, useFlexibleSync); const obj = await new Promise<MixedClass>((resolve) => { this.realm @@ -318,7 +309,7 @@ function describeTypes(useFlexibleSync: boolean) { } } -describe("mixed synced", () => { +describe.only("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); From ba4813541d8d060b559ba027f00f33ead3b49dfb Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 10:49:11 +0200 Subject: [PATCH 30/43] Reinstated tests --- .../tests/src/tests/sync/mixed.ts | 342 +++++++++++++++++- 1 file changed, 334 insertions(+), 8 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 04c9586f3b..2c58988608 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -17,11 +17,14 @@ //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import Realm, { BSON, Mixed, ObjectSchema } from "realm"; +import Realm, { BSON, Configuration, Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; +import { OpenRealmConfiguration, openRealm } from "../../utils/open-realm"; +import { sleep } from "../../utils/sleep"; +import { LogEntry, LoggerCallback2 } from "realm/dist/public-types/Logger"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; @@ -310,15 +313,338 @@ function describeTypes(useFlexibleSync: boolean) { } describe.only("mixed synced", () => { - describe("partition-based sync roundtrip", function () { - this.longTimeout(); - importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - describeTypes(false); - }); + // describe("partition-based sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + // describeTypes(false); + // }); + + // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + // this.longTimeout(); + // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + // describeTypes(true); + // }); + + describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { + // const dirname = "/Users/papafe/Desktop"; + // const filePath = path.join(dirname, "logs.txt"); // Specify your log file path here + + // fs.unlink(filePath, (err) => { + // if (err) { + // console.error("Failed to delete file:", err); + // } + // }); + + // const callback: LoggerCallback2 = (entry: LogEntry) => { + // const message = `[${entry.category}-${entry.level}] - ${entry.message}`; + // // console.log(message); + // fs.appendFile(filePath, message, (err) => { + // if (err) { + // console.error("Error writing to log file:", err); + // } + // }); + // }; + + // Realm.setLogger(callback); + // Realm.setLogLevel("trace"); - describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - describeTypes(true); + + type MultiRealmContext = { + realm1: Realm; + realm2: Realm; + config1: Configuration; + config2: Configuration; + } & AppContext & + Mocha.Context; + + beforeEach(async function (this: MultiRealmContext) { + const config = { + schema: [MixedClass], + sync: { flexible: true }, + } satisfies OpenRealmConfiguration; + + this.realm1 = await logInAndGetRealm(this.app, config); + this.realm2 = await logInAndGetRealm(this.app, config); + this.config1 = { ...config, sync: this.realm1.syncSession?.config }; + this.config2 = { ...config, sync: this.realm2.syncSession?.config }; + }); + + afterEach(async function (this: MultiRealmContext) { + closeAndDeleteRealms(this.config1, this.config2); + }); + + function closeAndDeleteRealms(...configs: Configuration[]) { + for (const config of configs) { + Realm.deleteFile(config); + } + Realm.clearTestState(); + } + + async function waitForSynchronization({ + uploadRealm, + downloadRealm, + }: { + uploadRealm: Realm; + downloadRealm: Realm; + }) { + await uploadRealm.syncSession?.uploadAllLocalChanges(); + await downloadRealm.syncSession?.downloadAllServerChanges(); + } + + async function logInAndGetRealm(app: Realm.App, config: OpenRealmConfiguration) { + const user = await app.logIn(Realm.Credentials.anonymous(false)); + const realm = (await openRealm(config, user)).realm; + + await setupIfFlexiblySync(realm, true); + + // It seems that if I use this I don't get the same core crash, but the test doesn't complete + // realm.write(() => { + // realm.delete(realm.objects(MixedClass)); + // }); + + // await realm.syncSession?.uploadAllLocalChanges(); + + return realm; + } + + function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise<void> { + return new Promise((resolve) => { + obj.addListener((_, changes) => { + if (changes.changedProperties.includes(propertyName)) { + obj.removeAllListeners(); + resolve(); + } + }); + }); + } + + function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise<MixedClass> { + return new Promise<MixedClass>((resolve) => { + realm + .objects(MixedClass) + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + } + + async function getObjects( + realm1: Realm, + realm2: Realm, + initialVal: Mixed, + ): Promise<{ obj1: MixedClass; obj2: MixedClass }> { + const obId = new Realm.BSON.ObjectId(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: initialVal, + }); + }); + + const obj2 = await waitForMixedClassObj(realm2, obId); + return { obj1, obj2 }; + } + + it("value change", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, null); + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + for (const val of valuesToInsert) { + realm1.write(() => { + obj1.value = val; + }); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, val, realm2); + } + }); + + it("list adding", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, []); + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + //We will keep this list updated with the values we expect to find + const expectedList = []; + + //Adding elements one by one and verifying the list is synchronized + for (const val of valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.List).push(val); + }); + expectedList.push(val); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + }); + + it("list removing", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, valuesToInsert); + + //We will keep this list updated with the values we expect to find + const expectedList = [...valuesToInsert]; + + //Removing elements one by one and verifying the list is synchronized + for (let i = 0; i < valuesToInsert.length; i++) { + realm1.write(() => { + (obj1.value as Realm.List).pop(); + }); + expectedList.pop(); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + expect((obj1.value as Realm.List).length).equals(0); + expect((obj2.value as Realm.List).length).equals(0); + }); + + it("list modification", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const valuesToInsert = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, ["test"]); + + //We will keep this list updated with the values we expect to find + const expectedList: Mixed[] = ["test"]; + + //Changing the first element and verifying the list is synchronized + for (const val of valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.List)[0] = val; + }); + expectedList[0] = val; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList, realm2); + } + + obj2.removeAllListeners(); + }); + + it.skip("dictionary adding", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, {}); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Adding elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + const val = valuesToInsert[key]; + realm1.write(() => { + (obj1.value as Realm.Dictionary)[key] = val; + }); + expectedDict[key] = val; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); + + it.skip("dictionary removing", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, valuesToInsert); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict = { ...valuesToInsert }; + + //Removing elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + realm1.write(() => { + (obj1.value as Realm.Dictionary).remove(key); + }); + delete expectedDict[key]; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); + + it.skip("dictionary modification", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const valuesToInsert: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const keyString = "keyString"; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, { [keyString]: 1 }); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Modifying elements one by one and verifying the dictionary is synchronized + for (const key in valuesToInsert) { + const val = valuesToInsert[key]; + realm1.write(() => { + (obj1.value as Realm.Dictionary)[keyString] = val; + }); + expectedDict[keyString] = val; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict, realm2); + } + }); }); }); From 89de282e73378f67bd8c190a43928315e9e289d9 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:09:53 +0200 Subject: [PATCH 31/43] Small correction --- integration-tests/tests/src/tests/sync/mixed.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 2c58988608..a70fa66089 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -23,8 +23,6 @@ import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../ import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; import { OpenRealmConfiguration, openRealm } from "../../utils/open-realm"; -import { sleep } from "../../utils/sleep"; -import { LogEntry, LoggerCallback2 } from "realm/dist/public-types/Logger"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; @@ -468,7 +466,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, val, realm2); + defaultTester(obj2.value, val); } }); @@ -495,7 +493,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, expectedList, realm2); + defaultTester(obj2.value, expectedList); } }); @@ -523,7 +521,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, expectedList, realm2); + defaultTester(obj2.value, expectedList); } expect((obj1.value as Realm.List).length).equals(0); @@ -554,7 +552,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, expectedList, realm2); + defaultTester(obj2.value, expectedList); } obj2.removeAllListeners(); @@ -585,7 +583,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, expectedDict, realm2); + defaultTester(obj2.value, expectedDict); } }); @@ -643,7 +641,7 @@ describe.only("mixed synced", () => { await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); await waitPromise; - defaultTester(obj2.value, expectedDict, realm2); + defaultTester(obj2.value, expectedDict); } }); }); From 2711dd8568ca27d653655789fb8df7784354b394 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:29:55 +0200 Subject: [PATCH 32/43] Logging to file --- .../tests/src/hooks/import-app-before.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index c3bad5017c..6c561cc61d 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -138,15 +138,32 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo throw new Error("Expected at most 1 database name in the config"); } - Realm.App.Sync.setLogLevel(this.app, syncLogLevel); + const dirName = "/Users/papafe/Desktop/"; + const filePath = path.join(dirName, "logs.txt"); // Specify your log file path here + + fs.unlink(filePath, (err) => { + if (err) { + console.error("Failed to delete file:", err); + } else { + console.log("File successfully deleted."); + } + }); + + Realm.App.Sync.setLogLevel(this.app, "trace"); // Set a default logger as Android does not forward stdout Realm.App.Sync.setLogger(this.app, (level, message) => { const time = new Date().toISOString().split("T")[1].replace("Z", ""); const magentaTime = `\x1b[35m${time}`; const greenLogLevel = `\x1b[32m${REALM_LOG_LEVELS[level].toUpperCase()}`; const whiteMessage = `\x1b[37m${message}}`; + const logMessage = `${time}: ${REALM_LOG_LEVELS[level].toUpperCase()}:\t${message}\n`; + fs.appendFile(filePath, logMessage, (err) => { + if (err) { + console.error("Error writing to log file:", err); + } + }); - console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); + // console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); }); } }); From e56b23333f38143fb97e3dae116ccdf8c06ed396 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:56:42 +0200 Subject: [PATCH 33/43] Improvements --- .../tests/src/hooks/import-app-before.ts | 36 +++++++++---------- .../tests/src/tests/sync/mixed.ts | 28 +++------------ 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index 6c561cc61d..30c7fa4c2e 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -138,18 +138,18 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo throw new Error("Expected at most 1 database name in the config"); } - const dirName = "/Users/papafe/Desktop/"; - const filePath = path.join(dirName, "logs.txt"); // Specify your log file path here - - fs.unlink(filePath, (err) => { - if (err) { - console.error("Failed to delete file:", err); - } else { - console.log("File successfully deleted."); - } - }); - - Realm.App.Sync.setLogLevel(this.app, "trace"); + // const dirName = "/Users/papafe/Desktop/"; + // const filePath = path.join(dirName, "logs.txt"); // Specify your log file path here + + // fs.unlink(filePath, (err) => { + // if (err) { + // console.error("Failed to delete file:", err); + // } else { + // console.log("File successfully deleted."); + // } + // }); + + Realm.App.Sync.setLogLevel(this.app, "warn"); // Set a default logger as Android does not forward stdout Realm.App.Sync.setLogger(this.app, (level, message) => { const time = new Date().toISOString().split("T")[1].replace("Z", ""); @@ -157,13 +157,13 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo const greenLogLevel = `\x1b[32m${REALM_LOG_LEVELS[level].toUpperCase()}`; const whiteMessage = `\x1b[37m${message}}`; const logMessage = `${time}: ${REALM_LOG_LEVELS[level].toUpperCase()}:\t${message}\n`; - fs.appendFile(filePath, logMessage, (err) => { - if (err) { - console.error("Error writing to log file:", err); - } - }); + // fs.appendFile(filePath, logMessage, (err) => { + // if (err) { + // console.error("Error writing to log file:", err); + // } + // }); - // console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); + console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); }); } }); diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index a70fa66089..c9c707cf05 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -57,7 +57,8 @@ const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6 function getMixedList(realm: Realm) { const obj = realm.create(MixedClass, { _id: new BSON.ObjectId() }); - return [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; + // return [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; + return [obj]; } function getMixedDict(realm: Realm) { @@ -324,28 +325,6 @@ describe.only("mixed synced", () => { // }); describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { - // const dirname = "/Users/papafe/Desktop"; - // const filePath = path.join(dirname, "logs.txt"); // Specify your log file path here - - // fs.unlink(filePath, (err) => { - // if (err) { - // console.error("Failed to delete file:", err); - // } - // }); - - // const callback: LoggerCallback2 = (entry: LogEntry) => { - // const message = `[${entry.category}-${entry.level}] - ${entry.message}`; - // // console.log(message); - // fs.appendFile(filePath, message, (err) => { - // if (err) { - // console.error("Error writing to log file:", err); - // } - // }); - // }; - - // Realm.setLogger(callback); - // Realm.setLogLevel("trace"); - this.longTimeout(); importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); @@ -365,6 +344,7 @@ describe.only("mixed synced", () => { this.realm1 = await logInAndGetRealm(this.app, config); this.realm2 = await logInAndGetRealm(this.app, config); + this.config1 = { ...config, sync: this.realm1.syncSession?.config }; this.config2 = { ...config, sync: this.realm2.syncSession?.config }; }); @@ -397,7 +377,7 @@ describe.only("mixed synced", () => { await setupIfFlexiblySync(realm, true); - // It seems that if I use this I don't get the same core crash, but the test doesn't complete + // //It seems that if I use this I don't get the same core crash, but the test doesn't complete // realm.write(() => { // realm.delete(realm.objects(MixedClass)); // }); From 71e8524cfdbdee1d6094e01118fc40390d1270d4 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:57:40 +0200 Subject: [PATCH 34/43] Remove unused --- integration-tests/tests/src/tests/sync/mixed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 04c9586f3b..f7d9ce7864 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -24,7 +24,7 @@ import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); -type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed, realm?: Realm) => void; +type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; class MixedClass extends Realm.Object<MixedClass> { _id!: Realm.BSON.ObjectId; From fa9d2ea8a8e8039bfd6ac8dd462e9be9a6c243ef Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:09:35 +0200 Subject: [PATCH 35/43] Removed realm use --- integration-tests/tests/src/tests/sync/mixed.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index f7d9ce7864..79c53f633c 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -158,8 +158,8 @@ function describeRoundtrip({ valueTester?: ValueTester; useFlexibleSync: boolean; }) { - function performTest(actual: Realm.Mixed, inserted: Realm.Mixed, realm: Realm) { - valueTester(actual, inserted, realm); + function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { + valueTester(actual, inserted); } describe(`roundtrip of '${typeName}'`, () => { @@ -200,11 +200,11 @@ function describeRoundtrip({ expect(typeof obj).equals("object"); // Test the single value - performTest(obj.value, this.value, this.realm); + performTest(obj.value, this.value); // Test the list of values expect(obj.list.length).equals(4); const firstElement = obj.list[0]; - performTest(firstElement, this.value, this.realm); + performTest(firstElement, this.value); // No need to keep these around delete this._id; delete this.value; From 14e9a3276b340dc95e6f0c8e721157af3c1e0838 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:20:50 +0200 Subject: [PATCH 36/43] trying to improve testing --- .../tests/src/tests/sync/mixed.ts | 82 +++++++++++++++---- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 79c53f633c..3c9dbc640d 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -23,7 +23,7 @@ import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../ import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; -type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); +type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed) | ((realm: Realm) => { values: Mixed; expected: Mixed }); type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; class MixedClass extends Realm.Object<MixedClass> { @@ -54,15 +54,20 @@ const nullValue = null; const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; function getMixedList(realm: Realm) { - const obj = realm.create(MixedClass, { _id: new BSON.ObjectId() }); + const expectedObj = { _id: new BSON.ObjectId() }; + const obj = realm.create(MixedClass, expectedObj); - return [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; + const values = [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; + const expected = [bool, int, double, d128, string, oid, uuid, nullValue, date, data, expectedObj]; + + return { values, expected }; } function getMixedDict(realm: Realm) { - const obj = realm.create(MixedClass, { _id: new BSON.ObjectId() }); + const expectedObj = { _id: new BSON.ObjectId() }; + const obj = realm.create(MixedClass, expectedObj); - return { + const values = { bool, int, double, @@ -75,18 +80,53 @@ function getMixedDict(realm: Realm) { data, obj, }; + + const expected = { + bool, + int, + double, + d128, + string, + oid, + uuid, + nullValue, + date, + data, + obj: expectedObj, + }; + + return { values, expected }; } function getNestedMixedList(realm: Realm) { - return [...getMixedList(realm), getMixedList(realm), getMixedDict(realm)]; + const mixList1 = getMixedList(realm); + const mixList2 = getMixedList(realm); + const mixDict = getMixedDict(realm); + + const values = [...mixList1.values, mixList2.values, mixDict.values]; + const expected = [...mixList1.expected, mixList2.expected, mixDict.expected]; + + return { values, expected }; } function getNestedMixedDict(realm: Realm) { - return { - ...getMixedDict(realm), - innerList: getMixedList(realm), - innerDict: getMixedDict(realm), + const mixDict1 = getMixedDict(realm); + const mixDict2 = getMixedDict(realm); + const mixList = getMixedList(realm); + + const values = { + ...mixDict1.values, + innerDict: mixDict2.values, + innerList: mixList.values, + }; + + const expected = { + ...mixDict1.expected, + innerDict: mixDict2.expected, + innerList: mixList.expected, }; + + return { values, expected }; } function expectJsArray(value: unknown): asserts value is unknown[] { @@ -119,7 +159,9 @@ function defaultTester(actual: unknown, inserted: unknown) { actualBinaryView.forEach((item, index) => defaultTester(item, insertedBinaryView[index])); } else if (actual instanceof Realm.Object) { expect(actual).instanceOf(MixedClass); - expect(inserted).instanceOf(MixedClass); + const actualMixed = actual as MixedClass; + const insertedMixed = inserted as MixedClass; + defaultTester(actualMixed._id, insertedMixed._id); } else { expect(String(actual)).equals(String(inserted)); } @@ -172,7 +214,15 @@ function describeRoundtrip({ await setupIfFlexiblySync(this.realm, useFlexibleSync); this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { - this.value = typeof value === "function" ? value(this.realm) : value; + if (typeof value === "function") { + const valueResult = value(this.realm); + if ("expected" in valueResult && "values" in valueResult) { + this.value = valueResult.values; + this.expected = valueResult.expected; + } + } else { + this.value = value; + } this.realm.create(MixedClass, { _id: this._id, value: this.value, @@ -199,12 +249,14 @@ function describeRoundtrip({ }); expect(typeof obj).equals("object"); + + const testVal = this.expected === undefined ? this.value : this.expected; // Test the single value - performTest(obj.value, this.value); + performTest(obj.value, testVal); // Test the list of values expect(obj.list.length).equals(4); const firstElement = obj.list[0]; - performTest(firstElement, this.value); + performTest(firstElement, testVal); // No need to keep these around delete this._id; delete this.value; @@ -281,7 +333,7 @@ function describeTypes(useFlexibleSync: boolean) { }); if (useFlexibleSync) { - describe("collections in mixed", () => { + describe.only("collections in mixed", () => { describeRoundtrip({ typeName: "list", value: getMixedList, From 2ec8d2503e612fd72c5d8024c2748317868a9dc2 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:43:18 +0200 Subject: [PATCH 37/43] Corrected testing --- .../tests/src/tests/sync/mixed.ts | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 3c9dbc640d..9796aea44d 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -129,12 +129,12 @@ function getNestedMixedDict(realm: Realm) { return { values, expected }; } -function expectJsArray(value: unknown): asserts value is unknown[] { - expect(value).to.be.an("array"); +function expectRealmList(value: unknown): asserts value is Realm.List<unknown> { + expect(value).instanceOf(Realm.List); } -function expectJsObject(value: unknown): asserts value is Record<string, unknown> { - expect(value).to.be.an("object"); +function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary<unknown> { + expect(value).instanceOf(Realm.Dictionary); } /** @@ -143,21 +143,22 @@ function expectJsObject(value: unknown): asserts value is Record<string, unknown * @param inserted The value inserted locally before upload. */ function defaultTester(actual: unknown, inserted: unknown) { - if (actual instanceof Realm.List) { - expectJsArray(inserted); + if (inserted instanceof Array) { + expectRealmList(actual); expect(actual.length).equals(inserted.length); - actual.forEach((item, index) => defaultTester(item, inserted[index])); - } else if (actual instanceof Realm.Dictionary) { - expectJsObject(inserted); + inserted.forEach((item, index) => defaultTester(actual[index], item)); + } else if (inserted != null && typeof inserted === "object" && "d128" in inserted) { + expectRealmDictionary(actual); + const insertedKeys = Object.keys(actual); const actualKeys = Object.keys(actual); - expect(actualKeys).members(Object.keys(inserted)); - actualKeys.forEach((key) => defaultTester(actual[key], inserted[key])); - } else if (actual instanceof ArrayBuffer) { - const actualBinaryView = new Uint8Array(actual); + expect(insertedKeys).members(actualKeys); + insertedKeys.forEach((key) => defaultTester(actual[key], (inserted as Record<string, unknown>)[key])); + } else if (inserted instanceof ArrayBuffer) { + const actualBinaryView = new Uint8Array(actual as ArrayBuffer); const insertedBinaryView = new Uint8Array(inserted as ArrayBuffer); expect(actualBinaryView.byteLength).equals(insertedBinaryView.byteLength); - actualBinaryView.forEach((item, index) => defaultTester(item, insertedBinaryView[index])); - } else if (actual instanceof Realm.Object) { + insertedBinaryView.forEach((item, index) => defaultTester(item, actualBinaryView[index])); + } else if (inserted != null && typeof inserted === "object" && "_id" in inserted) { expect(actual).instanceOf(MixedClass); const actualMixed = actual as MixedClass; const insertedMixed = inserted as MixedClass; @@ -219,6 +220,8 @@ function describeRoundtrip({ if ("expected" in valueResult && "values" in valueResult) { this.value = valueResult.values; this.expected = valueResult.expected; + } else { + this.value = valueResult; } } else { this.value = value; @@ -333,7 +336,7 @@ function describeTypes(useFlexibleSync: boolean) { }); if (useFlexibleSync) { - describe.only("collections in mixed", () => { + describe("collections in mixed", () => { describeRoundtrip({ typeName: "list", value: getMixedList, From f7894f4fb2f348e430187256d4ba21fb00a5fb53 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:51:43 +0200 Subject: [PATCH 38/43] Removed only --- integration-tests/tests/src/tests/sync/mixed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 9796aea44d..4b050848cc 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -364,7 +364,7 @@ function describeTypes(useFlexibleSync: boolean) { } } -describe.only("mixed synced", () => { +describe("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); From 8f81629426afc74989f43473b3ef6f32c235aa28 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:09:58 +0200 Subject: [PATCH 39/43] Added comment --- integration-tests/tests/src/tests/sync/mixed.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 4b050848cc..70df1aa378 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -23,7 +23,16 @@ import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../ import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; -type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed) | ((realm: Realm) => { values: Mixed; expected: Mixed }); +/** + * A function type that generates values to inserted and expected values used with the default tester. + * The input realm is necessary to create and add objects to the realm before using them in the tests. + * The distinction between "values" and "expected" is necessary because most tests here close the realm after using + * the objects in "values", so they can't be used with the default tester (that happens at a later time). + * "expected" contains just an object instead of a RealmObject that can be used for testing. + */ +type ValueAndExpectedGenerator = (realm: Realm) => { values: Mixed; expected: Mixed }; + +type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed) | ValueAndExpectedGenerator; type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; class MixedClass extends Realm.Object<MixedClass> { From 4aeb00828aaae67da491c3d4ece7222a84fc0369 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 2 May 2024 13:03:08 +0200 Subject: [PATCH 40/43] Reinstated missing --- .../tests/src/tests/sync/mixed.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 70a3cc4124..ab2e5b2d80 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -392,18 +392,18 @@ function describeTypes(useFlexibleSync: boolean) { } } -describe.only("mixed synced", () => { - // describe("partition-based sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); - // describeTypes(false); - // }); - - // describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { - // this.longTimeout(); - // importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); - // describeTypes(true); - // }); +describe("mixed synced", () => { + describe("partition-based sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); + describeTypes(false); + }); + + describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + describeTypes(true); + }); describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { this.longTimeout(); From 67abfe9c02bfde0e319ca061a1c179a01c8fda71 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 2 May 2024 13:06:35 +0200 Subject: [PATCH 41/43] Removed unused --- .../tests/src/hooks/import-app-before.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index 30c7fa4c2e..b482698ab7 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -138,17 +138,6 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo throw new Error("Expected at most 1 database name in the config"); } - // const dirName = "/Users/papafe/Desktop/"; - // const filePath = path.join(dirName, "logs.txt"); // Specify your log file path here - - // fs.unlink(filePath, (err) => { - // if (err) { - // console.error("Failed to delete file:", err); - // } else { - // console.log("File successfully deleted."); - // } - // }); - Realm.App.Sync.setLogLevel(this.app, "warn"); // Set a default logger as Android does not forward stdout Realm.App.Sync.setLogger(this.app, (level, message) => { @@ -156,12 +145,6 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo const magentaTime = `\x1b[35m${time}`; const greenLogLevel = `\x1b[32m${REALM_LOG_LEVELS[level].toUpperCase()}`; const whiteMessage = `\x1b[37m${message}}`; - const logMessage = `${time}: ${REALM_LOG_LEVELS[level].toUpperCase()}:\t${message}\n`; - // fs.appendFile(filePath, logMessage, (err) => { - // if (err) { - // console.error("Error writing to log file:", err); - // } - // }); console.log(`${magentaTime}: ${greenLogLevel}:\t${whiteMessage}`); }); From f0c3ffc7b688e5bc1d431688c45f0dcfca20ee6e Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 2 May 2024 13:07:40 +0200 Subject: [PATCH 42/43] Small corrections --- .../tests/src/hooks/import-app-before.ts | 2 +- integration-tests/tests/src/tests/sync/mixed.ts | 17 +---------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/integration-tests/tests/src/hooks/import-app-before.ts b/integration-tests/tests/src/hooks/import-app-before.ts index b482698ab7..c3bad5017c 100644 --- a/integration-tests/tests/src/hooks/import-app-before.ts +++ b/integration-tests/tests/src/hooks/import-app-before.ts @@ -138,7 +138,7 @@ export function importAppBefore(config: AppConfig | { config: AppConfig }, sdkCo throw new Error("Expected at most 1 database name in the config"); } - Realm.App.Sync.setLogLevel(this.app, "warn"); + Realm.App.Sync.setLogLevel(this.app, syncLogLevel); // Set a default logger as Android does not forward stdout Realm.App.Sync.setLogger(this.app, (level, message) => { const time = new Date().toISOString().split("T")[1].replace("Z", ""); diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index ab2e5b2d80..2df8a5f712 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -52,20 +52,6 @@ class MixedClass extends Realm.Object<MixedClass> { }; } -class InnerClass extends Realm.Object<InnerClass> { - _id!: Realm.BSON.ObjectId; - intValue!: number; - - static schema: ObjectSchema = { - name: "InnerClass", - properties: { - _id: "objectId", - floatValue: "float", - }, - primaryKey: "_id", - }; -} - const bool = true; const int = 1; const double = 123.456; @@ -419,7 +405,7 @@ describe("mixed synced", () => { beforeEach(async function (this: MultiRealmContext) { const config = { - schema: [MixedClass, InnerClass], + schema: [MixedClass], sync: { flexible: true }, } satisfies OpenRealmConfiguration; @@ -458,7 +444,6 @@ describe("mixed synced", () => { await realm.subscriptions.update((mutableSubs) => { mutableSubs.add(realm.objects(MixedClass)); - mutableSubs.add(realm.objects(InnerClass)); }); await realm.subscriptions.waitForSynchronization(); From 3c3bde99565568bccefb7ca3c836fc733e8b9e13 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 8 May 2024 10:28:30 +0200 Subject: [PATCH 43/43] Removed comment --- integration-tests/tests/src/tests/sync/mixed.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 2df8a5f712..30f6f77d54 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -448,13 +448,6 @@ describe("mixed synced", () => { await realm.subscriptions.waitForSynchronization(); - // //It seems that if I use this I don't get the same core crash, but the test doesn't complete - // realm.write(() => { - // realm.delete(realm.objects(MixedClass)); - // }); - - // await realm.syncSession?.uploadAllLocalChanges(); - return realm; }