From 9cbb1f8a2eb57dec0aacac8b0b807db1bf0c2c6f 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] 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 --- CHANGELOG.md | 48 +- .../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/bindgen/vendor/realm-core | 2 +- 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 | 173 +- packages/realm/src/PropertyHelpers.ts | 195 +- packages/realm/src/Realm.ts | 24 +- packages/realm/src/Results.ts | 102 +- packages/realm/src/Set.ts | 117 +- packages/realm/src/TypeHelpers.ts | 70 +- ...ers.test.ts => collection-helpers.test.ts} | 4 +- 23 files changed, 3879 insertions(+), 1024 deletions(-) rename packages/realm/src/tests/{PropertyHelpers.test.ts => collection-helpers.test.ts} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2b70df8ee..da0d7df75b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,53 @@ * None ### Enhancements -* None +* 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 * Fixed `User#callFunction` to correctly pass arguments to the server. Previously they would be sent as an array, so if your server-side function used to handle the unwrapping of arguments, it would need an update too. The "functions factory" pattern of calling `user.functions.sum(1, 2, 3)` wasn't affected by this bug. Thanks to @deckyfx for finding this and suggesting the fix! ([#6447](https://github.com/realm/realm-js/issues/6447), since v12.0.0) diff --git a/integration-tests/tests/src/tests/dictionary.ts b/integration-tests/tests/src/tests/dictionary.ts index 145287f8e97..9f8915bd485 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 = { dict: Realm.Dictionary; @@ -60,17 +59,6 @@ const DictTypedSchema: Realm.ObjectSchema = { }, }; -const DictMixedSchema = { - name: "MixedDictionary", - properties: { - dict1: "mixed{}", - dict2: "mixed{}", - }, -}; - -type IDictSchema = { - fields: Record; -}; type ITwoDictSchema = { dict1: Record; dict2: Record; @@ -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", {}); const item2 = this.realm.create("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; + 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 40b408ed06e..64e9b536b57 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(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(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 34551a7fe88..f98d6824ffd 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 = { +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> = { bool, int, double, @@ -166,8 +186,8 @@ const flatDictionaryAllTypes: Record = { 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(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 { + function expectRealmList(value: unknown): asserts value is Realm.List { expect(value).instanceOf(Realm.List); } - function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary { + function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary { 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 { + 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 { + 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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; + }) { + 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(MixedSchema.name, { - value: [...flatListAllTypes, realmObject], - }); + const unmanagedList = [...primitiveTypesList, realmObject]; + + const list1 = this.realm.create(MixedSchema.name, { + mixed: unmanagedList, + }).mixed; + const list2 = this.realm.create(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(CollectionsOfMixedSchema.name, { - list: [...flatListAllTypes, realmObject], - }); - expectRealmList(realmObjectWithList.list); + const listToInsert = this.realm.create(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(MixedSchema.name, { value: realmObjectWithList.list }); + const list1 = this.realm.create(MixedSchema.name, { mixed: listToInsert }).mixed; + const list2 = this.realm.create(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(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(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: unmanagedList }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create(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(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(MixedSchema.name, { mixed: [] }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { mixed: unmanagedList }); + const created2 = this.realm.create(CollectionsOfMixedSchema.name, { + list: unmanagedList, }); - const createdWithoutProto = this.realm.create(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(MixedSchema.name, { mixed: [unmanagedList] }).mixed; + const list2 = this.realm.create(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(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create(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(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create(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(CollectionsOfMixedSchema.name, { - dictionary: { ...flatDictionaryAllTypes, realmObject }, - }); - expectRealmDictionary(realmObjectWithDictionary.dictionary); + const dictionaryToInsert = this.realm.create(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(MixedSchema.name, { value: realmObjectWithDictionary.dictionary }); + const dictionary1 = this.realm.create(MixedSchema.name, { + mixed: dictionaryToInsert, + }).mixed; + const dictionary2 = this.realm.create(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(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(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(MixedSchema.name, { - value: { ...embeddedObject }, - }); + const dictionary1 = this.realm.create(MixedSchema.name, { + mixed: { ...embeddedObject }, + }).mixed; + const dictionary2 = this.realm.create(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(MixedSchema.name, { - value: { ...customObject }, - }); + // Spread the custom object in order to use its entries as a dictionary in Mixed. + const dictionary1 = this.realm.create(MixedSchema.name, { + mixed: { ...customObject }, + }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { + mixed: unmanagedDictionary, + }).mixed; + const dictionary2 = this.realm.create(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(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create(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(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(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create(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(MixedSchema.name, { mixed: {} }).mixed; + const dictionary2 = this.realm.create(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 = {}; + const { created1, created2 } = this.realm.write(() => { + const created1 = this.realm.create(MixedSchema.name, { mixed: unmanagedDictionary }); + const created2 = this.realm.create(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(MixedSchema.name, { + mixed: { key: unmanagedDictionary }, + }).mixed; + const dictionary2 = this.realm.create(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(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(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(MixedSchema.name, { mixed: unmanagedList }); + }); - const insertedValues = Object.values(mixedDictionary); + const results = this.realm.objects(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(MixedSchema.name, { mixed: unmanagedList }); + }); - filtered = objects.filtered(`value['${key}'] == $0`, nonExistentValue); - expect(filtered.length).equals(0); + const results = this.realm.objects(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(MixedSchema.name, { mixed: unmanagedDictionary }); + }); + + const results = this.realm.objects(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(MixedSchema.name, { mixed: unmanagedDictionary }); + }); - filtered = objects.filtered(`value.${nonExistentKey} == $0`, valueToMatch); - expect(filtered.length).equals(0); + const results = this.realm.objects(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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> = { 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(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(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(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(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(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 = {}; + 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(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(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(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(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(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(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(MixedAndEmbeddedSchema.name, { - mixedValue: [], + // Create an object with the Mixed property as a list. + const { mixed: list } = this.realm.create(MixedAndEmbeddedSchema.name, { + mixed: ["original"], }); expectRealmList(list); - const { mixedValue: dictionary } = this.realm.create(MixedAndEmbeddedSchema.name, { - mixedValue: {}, + // Create an object with the Mixed property as a dictionary. + const { mixed: dictionary } = this.realm.create(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(MixedAndEmbeddedSchema.name); expect(objects.length).equals(3); - // Check that the list and dictionary are still empty. - expect((objects[1].mixedValue as Realm.List).length).equals(0); - expect(Object.keys(objects[2].mixedValue as Realm.Dictionary).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(MixedSchema.name, { value: "original" }); + return this.realm.create(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(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_collection() on list 'MixedClass.mixed' when empty"); + + expect(() => { + this.realm.write(() => { + list[0] = {}; + }); + }).to.throw("Requested index 0 calling set_collection() 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(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' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = []; + }); + }).to.throw("Requested index 0 calling set_collection() on list 'MixedClass.mixed' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = {}; + }); + }).to.throw("Requested index 0 calling set_collection() on list 'MixedClass.mixed' when empty"); + }); + + it("throws when assigning to list snapshot (Results)", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create(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(MixedSchema.name, { value: [1] }); + return this.realm.create(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(MixedSchema.name, { value: { prop: 1 } }); + return this.realm.create(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("MixedClass"); - let returnedData = [...new Uint8Array(mixedObjects[0].value as Iterable)]; + let returnedData = [...new Uint8Array(mixedObjects[0].mixed as Iterable)]; expect(returnedData).eql(uint8Values1); this.realm.write(() => { - mixedObjects[0].value = uint8Buffer2; + mixedObjects[0].mixed = uint8Buffer2; }); mixedObjects = this.realm.objects("MixedClass"); - returnedData = [...new Uint8Array(mixedObjects[0].value as Iterable)]; + returnedData = [...new Uint8Array(mixedObjects[0].mixed as Iterable)]; expect(returnedData).eql(uint8Values2); this.realm.write(() => { @@ -943,10 +2936,10 @@ describe("Mixed", () => { // Test with empty array this.realm.write(() => { - this.realm.create("MixedClass", { value: new Uint8Array(0) }); + this.realm.create("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("MixedClass"); - returnedData = [...new Uint16Array(uint16Objects[0].value as Iterable)]; + returnedData = [...new Uint16Array(uint16Objects[0].mixed as Iterable)]; 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("MixedClass"); - returnedData = [...new Uint32Array(uint32Objects[0].value as Iterable)]; + returnedData = [...new Uint32Array(uint32Objects[0].mixed as Iterable)]; 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 54ca6e92bde..37777d2f002 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 { + expect(value).instanceOf(Realm.List); +} + +function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary { + expect(value).instanceOf(Realm.Dictionary); +} + function noop() { /* tumbleweed */ } @@ -1379,306 +1387,576 @@ describe("Observable", () => { describe("Collections in Mixed", () => { class ObjectWithMixed extends Realm.Object { - 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; objectWithDictionary: Realm.Object & ObjectWithMixed; - list: Realm.List; - dictionary: Realm.Dictionary; - } & 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; - 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; - 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 8f37f66de9d..1ca46597ea8 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 fda460e022f..fac82bdefa2 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: @@ -244,7 +244,6 @@ classes: - get_results_description - feed_buffer - make_ssl_verify_callback - - get_mixed_type LogCategoryRef: methods: @@ -315,6 +314,8 @@ classes: - index_of_obj - get_obj - get_any + - get_list + - get_dictionary - sort_by_names - snapshot - max @@ -367,27 +368,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 @@ -398,10 +407,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 575d10e3879..67352699b79 100644 --- a/packages/realm/bindgen/src/templates/jsi.ts +++ b/packages/realm/bindgen/src/templates/jsi.ts @@ -79,7 +79,17 @@ function pushRet(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(); @@ -902,6 +912,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 c360f6207e7..e098f8c8eb9 100644 --- a/packages/realm/bindgen/src/templates/node.ts +++ b/packages/realm/bindgen/src/templates/node.ts @@ -839,6 +839,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 6cbadcb8f69..e6c838c90cc 100644 --- a/packages/realm/bindgen/src/templates/typescript.ts +++ b/packages/realm/bindgen/src/templates/typescript.ts @@ -123,7 +123,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(" | ")}; @@ -172,6 +172,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.mts", eslint); diff --git a/packages/realm/bindgen/vendor/realm-core b/packages/realm/bindgen/vendor/realm-core index 25358ae80ff..b14129c29ee 160000 --- a/packages/realm/bindgen/vendor/realm-core +++ b/packages/realm/bindgen/vendor/realm-core @@ -1 +1 @@ -Subproject commit 25358ae80ff02cfb2fc4dc69fb4c9dc1dc8fd8d0 +Subproject commit b14129c29ee1ef88abd560e0b82c4ab7eb746749 diff --git a/packages/realm/src/Collection.ts b/packages/realm/src/Collection.ts index 92e05dea4c4..73f37de6208 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 = OrderedCollectionAccessor | DictionaryAccessor; + +/** + * 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 = CollectionAccessor, > implements Iterable { + /** + * 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; + /** @internal */ private listeners: Listeners; /** @internal */ - constructor(addListener: CallbackAdder) { + constructor( + accessor: Accessor, + typeHelpers: TypeHelpers, + addListener: CallbackAdder, + ) { 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 649ef751924..9cc0eb181d1 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 = (dictionary: Dictionary, changes: DictionaryChangeSet) => void; const DEFAULT_PROPERTY_DESCRIPTOR: PropertyDescriptor = { configurable: true, enumerable: true }; const PROXY_HANDLER: ProxyHandler = { 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 = { * Dictionaries behave mostly like a JavaScript object i.e., as a key/value pair * where the key is a string. */ -export class Dictionary extends Collection { +export class Dictionary extends Collection< + string, + T, + [string, T], + [string, T], + DictionaryChangeCallback, + /** @internal */ + DictionaryAccessor +> { + /** @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, + typeHelpers: TypeHelpers, + ) { 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 extends Collection; + const proxied = new Proxy(this, PROXY_HANDLER as ProxyHandler); Object.defineProperty(this, REALM, { enumerable: false, @@ -153,37 +181,12 @@ export class Dictionary extends Collection extends Collection { - 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(realm, values, accessor, typeHelpers); + + for (const value of results.values()) { + yield value; } } @@ -231,15 +237,21 @@ export class Dictionary extends Collection { - 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(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 extends Collection extends Collection = { + get: (dictionary: binding.Dictionary, key: string) => T; + set: (dictionary: binding.Dictionary, key: string, value: T) => void; +}; + +type DictionaryAccessorFactoryOptions = { + realm: Realm; + typeHelpers: TypeHelpers; + itemType: binding.PropertyType; + isEmbedded?: boolean; +}; + +/** @internal */ +export function createDictionaryAccessor(options: DictionaryAccessorFactoryOptions): DictionaryAccessor { + return options.itemType === binding.PropertyType.Mixed + ? createDictionaryAccessorForMixed(options) + : createDictionaryAccessorForKnownType(options); +} + +function createDictionaryAccessorForMixed({ + realm, + typeHelpers, +}: Pick, "realm" | "typeHelpers">): DictionaryAccessor { + const { toBinding, fromBinding } = typeHelpers; + return { + get(dictionary, key) { + const value = dictionary.tryGetAny(key); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor({ realm, itemType: binding.PropertyType.Mixed, typeHelpers }); + return new List(realm, dictionary.getList(key), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor({ realm, itemType: binding.PropertyType.Mixed, typeHelpers }); + return new Dictionary(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({ + realm, + typeHelpers, + isEmbedded, +}: Omit, "itemType">): DictionaryAccessor { + 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, + 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 { + return isPOJO(value) || value instanceof Dictionary; +} + +/** @internal */ +export function isPOJO(value: unknown): value is Record { + 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 23441bafc9f..4a4dc393404 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 d37f3b7b365..2e4564e8b84 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 = Pick, "pop" | "push" | "shift" | "unshift" | "splice">; @@ -36,27 +43,36 @@ type PartiallyWriteableArray = Pick, "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 extends OrderedCollection implements PartiallyWriteableArray { +export class List + extends OrderedCollection< + T, + [number, T], + /** @internal */ + ListAccessor + > + implements PartiallyWriteableArray +{ /** * 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, typeHelpers: TypeHelpers) { 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection 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 extends OrderedCollection implements Partially this.internal.swap(index1, index2); } } + +/** + * Accessor for getting, setting, and inserting items in the binding collection. + * @internal + */ +export type ListAccessor = { + 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 = { + realm: Realm; + typeHelpers: TypeHelpers; + itemType: binding.PropertyType; + isEmbedded?: boolean; +}; + +/** @internal */ +export function createListAccessor(options: ListAccessorFactoryOptions): ListAccessor { + return options.itemType === binding.PropertyType.Mixed + ? createListAccessorForMixed(options) + : createListAccessorForKnownType(options); +} + +function createListAccessorForMixed({ + realm, + typeHelpers, +}: Pick, "realm" | "typeHelpers">): ListAccessor { + const { toBinding } = typeHelpers; + return { + get(list, index) { + const value = list.getAny(index); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new List(realm, list.getList(index), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new Dictionary(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({ + realm, + typeHelpers, + itemType, + isEmbedded, +}: Omit, "isMixed">): ListAccessor { + 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 958342b100a..e2656519464 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(objectType: string, propertyName: string): Results & T>; linkingObjects(objectType: Constructor, propertyName: string): Results; linkingObjects(objectType: string | Constructor, propertyName: string): Results { - 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 `'${targetObjectSchema.name}#${propertyName}' is not a relationship to '${originObjectSchema.name}'`, ); - const collectionHelpers: OrderedCollectionHelpers = { + const typeHelpers: TypeHelpers = { // 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({ 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(realm, results, accessor, typeHelpers); } /** @@ -578,6 +577,12 @@ export class RealmObject = ListAccessor | ResultsAccessor | SetAccessor; + /** * A sort descriptor is either a string containing one or more property names * separate by dots or an array with two items: `[propertyName, reverse]`. @@ -51,16 +67,12 @@ export type CollectionChangeSet = { newModifications: number[]; oldModifications: number[]; }; + export type CollectionChangeCallback = ( collection: OrderedCollection, 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 = { // TODO: Consider executing the `parseInt` first to optimize for index access over accessing a member on the list @@ -78,13 +90,19 @@ const PROXY_HANDLER: ProxyHandler = { 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; } } @@ -112,19 +130,39 @@ const PROXY_HANDLER: ProxyHandler = { * subscripting, enumerating with `for-of` and so on. * @see {@link https://mdn.io/Array | Array} */ -export abstract class OrderedCollection - extends Collection> +export abstract class OrderedCollection< + T = unknown, + EntryType extends [unknown, unknown] = [number, T], + /** @internal */ + Accessor extends OrderedCollectionAccessor = OrderedCollectionAccessor, + > + extends Collection< + number, + T, + EntryType, + T, + CollectionChangeCallback, + /** @internal */ + Accessor + > implements Omit, "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) { if (arguments.length === 0) { throw new IllegalConstructorError("OrderedCollection"); } - super((callback, keyPaths) => { + super(accessor, typeHelpers, (callback, keyPaths) => { return results.addNotificationCallback( (changes) => { try { @@ -147,6 +185,7 @@ export abstract class OrderedCollection); + // Get the class helpers for later use, if available const { objectType } = results; const classHelpers = typeof objectType === "string" && objectType !== "" ? realm.getClassHelpers(objectType) : null; @@ -163,12 +202,6 @@ export abstract class OrderedCollection 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. @@ -250,10 +275,9 @@ export abstract class OrderedCollection { - 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]; } } @@ -262,11 +286,10 @@ export abstract class OrderedCollection { - 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; } } @@ -291,7 +314,7 @@ export abstract class OrderedCollection { - 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 */ @@ -852,7 +885,7 @@ export abstract class OrderedCollection { 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") { @@ -868,7 +901,10 @@ export abstract class OrderedCollection { - 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); } private getPropertyColumnKey(name: string | undefined): binding.ColKey { @@ -911,3 +952,35 @@ export abstract class OrderedCollection = (collection: CollectionType, index: number) => T; + +type GetterFactoryOptions = { + fromBinding: TypeHelpers["fromBinding"]; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createDefaultGetter({ + fromBinding, + itemType, +}: GetterFactoryOptions): Getter { + const isObjectItem = itemType === binding.PropertyType.Object || itemType === binding.PropertyType.LinkingObjects; + return isObjectItem ? (...args) => getObject(fromBinding, ...args) : (...args) => getKnownType(fromBinding, ...args); +} + +function getObject( + fromBinding: TypeHelpers["fromBinding"], + collection: OrderedCollectionInternal, + index: number, +): T { + return fromBinding(collection.getObj(index)); +} + +function getKnownType( + fromBinding: TypeHelpers["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 92db3750f15..8914660083b 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); -} - export type HelperOptions = { realm: Realm; getClassHelpers: (name: string) => ClassHelpers; @@ -59,14 +61,14 @@ 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; }; export type PropertyHelpers = TypeHelpers & - PropertyAccessors & { + PropertyAccessor & { type: binding.PropertyType; columnKey: binding.ColKey; embedded: boolean; @@ -114,7 +116,7 @@ function embeddedSet({ typeHelpers: { toBinding }, columnKey }: PropertyOptions) }; } -type AccessorFactory = (options: PropertyOptions) => PropertyAccessors; +type AccessorFactory = (options: PropertyOptions) => PropertyAccessor; const ACCESSOR_FACTORIES: Partial> = { [binding.PropertyType.Object](options) { @@ -151,11 +153,9 @@ const ACCESSOR_FACTORIES: Partial> 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}`, @@ -165,13 +165,6 @@ const ACCESSOR_FACTORIES: Partial> 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"); @@ -184,71 +177,51 @@ const ACCESSOR_FACTORIES: Partial> 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}`, @@ -257,23 +230,28 @@ const ACCESSOR_FACTORIES: Partial> 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}"]`); @@ -285,7 +263,7 @@ const ACCESSOR_FACTORIES: Partial> }; }, [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}`, @@ -295,76 +273,64 @@ const ACCESSOR_FACTORIES: Partial> 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); } @@ -410,23 +376,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 { - 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 6ddac489ce2..cc797baae40 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -103,10 +103,10 @@ import { LogArgs, LogCategory, LogLevel, - LoggerCallbackArgs, LoggerCallback, LoggerCallback1, LoggerCallback2, + LoggerCallbackArgs, MapToDecorator, Metadata, MetadataMode, @@ -169,6 +169,7 @@ import { SyncError, SyncSession, TypeAssertionError, + TypeHelpers, Types, Unmanaged, Update, @@ -183,6 +184,7 @@ import { WaitForSync, assert, binding, + createResultsAccessor, defaultLogger, defaultLoggerLevel, extendDebug, @@ -1110,25 +1112,27 @@ export class Realm { objects(type: string): Results & T>; objects(type: Constructor): Results; objects(type: string | Constructor): Results { - 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(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 = { + 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({ realm: this, typeHelpers, itemType: binding.PropertyType.Object }); + return new Results(this, results, accessor, typeHelpers); } /** diff --git a/packages/realm/src/Results.ts b/packages/realm/src/Results.ts index 962f3a776e3..41e3560247c 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 extends OrderedCollection { +export class Results extends OrderedCollection< + T, + [number, T], + /** @internal */ + ResultsAccessor +> { /** * 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 extends OrderedCollection { * 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, typeHelpers: TypeHelpers) { 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 extends OrderedCollection { }); } + /** @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 extends OrderedCollection { * @since 2.0.0 */ update(propertyName: keyof Unmanaged, value: Unmanaged[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 extends OrderedCollection { } } +/** + * Accessor for getting items from the binding collection. + * @internal + */ +export type ResultsAccessor = { + get: (results: binding.Results, index: number) => T; +}; + +type ResultsAccessorFactoryOptions = { + realm: Realm; + typeHelpers: TypeHelpers; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createResultsAccessor(options: ResultsAccessorFactoryOptions): ResultsAccessor { + return options.itemType === binding.PropertyType.Mixed + ? createResultsAccessorForMixed(options) + : createResultsAccessorForKnownType(options); +} + +function createResultsAccessorForMixed({ + realm, + typeHelpers, +}: Omit, "itemType">): ResultsAccessor { + return { + get(results, index) { + const value = results.getAny(index); + switch (value) { + case binding.ListSentinel: { + const accessor = createListAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new List(realm, results.getList(index), accessor, typeHelpers) as T; + } + case binding.DictionarySentinel: { + const accessor = createDictionaryAccessor({ realm, typeHelpers, itemType: binding.PropertyType.Mixed }); + return new Dictionary(realm, results.getDictionary(index), accessor, typeHelpers) as T; + } + default: + return typeHelpers.fromBinding(value); + } + }, + }; +} + +function createResultsAccessorForKnownType({ + typeHelpers, + itemType, +}: Omit, "realm">): ResultsAccessor { + 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; diff --git a/packages/realm/src/Set.ts b/packages/realm/src/Set.ts index 37e1c8c1af3..237dc3a51e9 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 extends OrderedCollection { +export class RealmSet extends OrderedCollection< + T, + [T, T], /** @internal */ - private declare internal: binding.Set; + SetAccessor +> { + /** @internal */ + public declare readonly internal: binding.Set; /** @internal */ - constructor(realm: Realm, internal: binding.Set, helpers: OrderedCollectionHelpers) { + constructor(realm: Realm, internal: binding.Set, accessor: SetAccessor, typeHelpers: TypeHelpers) { 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 extends OrderedCollection { 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 extends OrderedCollection { */ 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 extends OrderedCollection { * @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 extends OrderedCollection { } } } + +/** + * Accessor for getting and setting items in the binding collection. + * @internal + */ +export type SetAccessor = { + 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 = { + realm: Realm; + typeHelpers: TypeHelpers; + itemType: binding.PropertyType; +}; + +/** @internal */ +export function createSetAccessor(options: SetAccessorFactoryOptions): SetAccessor { + return options.itemType === binding.PropertyType.Mixed + ? createSetAccessorForMixed(options) + : createSetAccessorForKnownType(options); +} + +function createSetAccessorForMixed({ + realm, + typeHelpers, +}: Omit, "itemType">): SetAccessor { + 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({ + realm, + typeHelpers, + itemType, +}: SetAccessorFactoryOptions): SetAccessor { + 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 d2a016c10f5..8330fe178e4 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 = { toBinding( value: T, @@ -141,12 +145,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; } @@ -166,40 +172,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 = { - 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; } @@ -354,9 +343,8 @@ const TYPES_MAPPING: Record 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), }; }, @@ -390,13 +378,14 @@ const TYPES_MAPPING: Record 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"); @@ -434,6 +423,11 @@ const TYPES_MAPPING: Record 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 96% rename from packages/realm/src/tests/PropertyHelpers.test.ts rename to packages/realm/src/tests/collection-helpers.test.ts index 685a3784b74..901576960d5 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 = {};