From 333c19d2de14d3d8cf542556112e32a2ffd47dbb 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/src/Collection.ts | 53 +- packages/realm/src/Dictionary.ts | 243 +- packages/realm/src/GeoSpatial.ts | 18 + packages/realm/src/List.ts | 249 +- packages/realm/src/Object.ts | 27 +- packages/realm/src/OrderedCollection.ts | 172 +- packages/realm/src/PropertyHelpers.ts | 195 +- packages/realm/src/Realm.ts | 22 +- packages/realm/src/Results.ts | 102 +- packages/realm/src/Set.ts | 117 +- packages/realm/src/TypeHelpers.ts | 69 +- ...ers.test.ts => collection-helpers.test.ts} | 17 +- 22 files changed, 3883 insertions(+), 1027 deletions(-) rename packages/realm/src/tests/{PropertyHelpers.test.ts => collection-helpers.test.ts} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c54c4ff94..7d14b4f7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ > This version communicates with Atlas Device Services through a different URL (https://services.cloud.mongodb.com). While we consider this an internal detail of the SDK, you might need to update rules in firewalls or other configuration that you've used to limit connections made by your app. ### Enhancements + * Updated bundled OpenSSL version to 3.2.0. ([realm/realm-core#7303](https://github.com/realm/realm-core/pull/7303)) * Improved performance of object notifiers with complex schemas by ~20%. ([realm/realm-core#7424](https://github.com/realm/realm-core/pull/7424)) * Improved performance with very large number of notifiers by ~75%. ([realm/realm-core#7424](https://github.com/realm/realm-core/pull/7424)) @@ -22,6 +23,53 @@ * Improved file compaction performance on platforms with page sizes greater than 4k (for example arm64 Apple platforms) for files less than 256 pages in size. ([realm/realm-core#7492](https://github.com/realm/realm-core/pull/7492)) * Added the ability to set the log level for one or more categories via `Realm.setLogLevel`. ([#6560](https://github.com/realm/realm-js/issues/6560)) * Added detection and better instructions when imported from the Expo Go app. ([#6523](https://github.com/realm/realm-js/pull/6523)) +* 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 * Aligned Dictionaries to Lists and Sets when they get cleared. ([#6205](https://github.com/realm/realm-core/issues/6205), since v10.3.0-rc.1) diff --git a/integration-tests/tests/src/tests/dictionary.ts b/integration-tests/tests/src/tests/dictionary.ts index 145287f8e9..9f8915bd48 100644 --- a/integration-tests/tests/src/tests/dictionary.ts +++ b/integration-tests/tests/src/tests/dictionary.ts @@ -20,7 +20,6 @@ import { expect } from "chai"; import Realm, { PropertySchema } from "realm"; import { openRealmBefore, openRealmBeforeEach } from "../hooks"; -import { sleep } from "../utils/sleep"; type Item = { 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 40b408ed06..64e9b536b5 100644 --- a/integration-tests/tests/src/tests/list.ts +++ b/integration-tests/tests/src/tests/list.ts @@ -711,7 +711,7 @@ describe("Lists", () => { Error, "Requested index 2 calling set() on list 'LinkTypesObject.arrayCol' when max is 1", ); - expect(() => (array[-1] = { doubleCol: 1 })).throws(Error, "Index -1 cannot be less than zero."); + expect(() => (array[-1] = { doubleCol: 1 })).throws(Error, "Cannot set item at negative index -1"); //@ts-expect-error TYPEBUG: our List type-definition expects index accesses to be done with a number , should probably be extended. array["foo"] = "bar"; @@ -772,6 +772,7 @@ describe("Lists", () => { openRealmBeforeEach({ schema: [LinkTypeSchema, TestObjectSchema, PersonListSchema, PersonSchema, PrimitiveArraysSchema], }); + it("are typesafe", function (this: RealmContext) { let obj: ILinkTypeSchema; let prim: IPrimitiveArraysSchema; @@ -792,8 +793,10 @@ describe("Lists", () => { //@ts-expect-error TYPEBUG: type missmatch, forcecasting shouldn't be done obj.arrayCol = [this.realm.create(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 34551a7fe8..1441ff024e 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -47,12 +47,12 @@ interface IMixedNullable { } interface IMixedSchema { - value: Realm.Mixed; + mixed: Realm.Mixed; } interface IMixedAndEmbedded { - mixedValue: Realm.Mixed; - embeddedObject: { value: Realm.Mixed }; + mixed: Realm.Mixed; + embeddedObject: { mixed: Realm.Mixed }; } interface IMixedWithDefaultCollections { @@ -105,14 +105,14 @@ const MixedNullableSchema: ObjectSchema = { const MixedSchema: ObjectSchema = { name: "MixedClass", properties: { - value: "mixed", + mixed: "mixed", }, }; const MixedAndEmbeddedSchema: ObjectSchema = { name: "MixedAndEmbedded", properties: { - mixedValue: "mixed", + mixed: "mixed", embeddedObject: "EmbeddedObject?", }, }; @@ -121,7 +121,7 @@ const EmbeddedObjectSchema: ObjectSchema = { name: "EmbeddedObject", embedded: true, properties: { - value: "mixed", + mixed: "mixed", }, }; @@ -135,7 +135,7 @@ const CollectionsOfMixedSchema: ObjectSchema = { }; const bool = true; -const int = 123; +const int = BigInt(123); const double = 123.456; const d128 = BSON.Decimal128.fromString("6.022e23"); const string = "hello"; @@ -145,12 +145,32 @@ const uuid = new BSON.UUID(); const nullValue = null; const uint8Values = [0, 1, 2, 4, 8]; const uint8Buffer = new Uint8Array(uint8Values).buffer; -const unmanagedRealmObject: IMixedSchema = { value: 1 }; - -// The `unmanagedRealmObject` is not added to these collections since a managed +// The `unmanagedRealmObject` is not added to the collections below since a managed // Realm object will be added by the individual tests after one has been created. -const flatListAllTypes: unknown[] = [bool, int, double, d128, string, date, oid, uuid, nullValue, uint8Buffer]; -const flatDictionaryAllTypes: Record = { +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() on list 'MixedClass.mixed' when empty"); + + expect(() => { + this.realm.write(() => { + list[0] = {}; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed' when empty"); + }); + + it("throws when setting a nested list item out of bounds", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + // Create a list containing an empty list as the Mixed value. + return this.realm.create(MixedSchema.name, { mixed: [[]] }); + }); + expectRealmList(list); + expect(list.length).equals(1); + + const nestedList = list[0]; + expectRealmList(nestedList); + expect(nestedList.length).equals(0); + + expect(() => { + this.realm.write(() => { + nestedList[0] = "primitive"; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = []; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + + expect(() => { + this.realm.write(() => { + nestedList[0] = {}; + }); + }).to.throw("Requested index 0 calling set() on list 'MixedClass.mixed[FIRST]' when empty"); + }); + + it("throws when assigning to list snapshot (Results)", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create(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 54ca6e92bd..37777d2f00 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -238,6 +238,14 @@ async function expectListenerRemoval({ addListener, removeListener, update }: Li await handle; } +function expectRealmList(value: unknown): asserts value is Realm.List { + 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 8f37f66de9..1ca46597ea 100644 --- a/integration-tests/tests/src/tests/results.ts +++ b/integration-tests/tests/src/tests/results.ts @@ -186,15 +186,15 @@ describe("Results", () => { expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[-1] = { doubleCol: 0 }; - }).throws("Index -1 cannot be less than zero."); + }).throws("Modifying a Results collection is not supported"); expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[0] = { doubleCol: 0 }; - }).throws("Assigning into a Results is not supported"); + }).throws("Modifying a Results collection is not supported"); expect(() => { //@ts-expect-error Should be an invalid write to read-only object. objects[1] = { doubleCol: 0 }; - }).throws("Assigning into a Results is not supported"); + }).throws("Modifying a Results collection is not supported"); expect(() => { objects.length = 0; }).throws("Cannot assign to read only property 'length'"); diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index 9ec1234bdb..bab61c6703 100644 --- a/packages/realm/bindgen/js_opt_in_spec.yml +++ b/packages/realm/bindgen/js_opt_in_spec.yml @@ -7,10 +7,10 @@ # * `classes` and their `methods` # * Methods, static methods, constructors, and properties in the general `spec.yml` # should all be listed in this opt-in list as `methods`. -# * `records` and their `fields`` +# * `records` and their `fields` # -# If all methods in a class, or all fields of a property, are opted out of, -# the entire class/property should be removed. +# If all methods in a class, or all fields of a record, are opted out of, +# the entire class/record should be removed. records: Property: @@ -262,7 +262,6 @@ classes: - feed_buffer - make_ssl_verify_callback - needs_file_format_upgrade - - get_mixed_type LogCategoryRef: methods: @@ -333,6 +332,8 @@ classes: - index_of_obj - get_obj - get_any + - get_list + - get_dictionary - sort_by_names - snapshot - max @@ -385,27 +386,35 @@ classes: Collection: methods: - get_object_schema + - get_type - size - is_valid - get_any - as_results + - snapshot List: methods: - make + - get_obj + - get_list + - get_dictionary - move - remove - remove_all - swap - delete_all - insert_any + - insert_collection - insert_embedded - set_any - set_embedded + - set_collection Set: methods: - make + - get_obj - insert_any - remove_any - remove_all @@ -416,10 +425,13 @@ classes: - make - get_keys - get_values + - get_list + - get_dictionary - contains - add_key_based_notification_callback - insert_any - insert_embedded + - insert_collection - try_get_any - remove_all - try_erase diff --git a/packages/realm/bindgen/src/templates/jsi.ts b/packages/realm/bindgen/src/templates/jsi.ts index 0b5e80bdea..639077ac6a 100644 --- a/packages/realm/bindgen/src/templates/jsi.ts +++ b/packages/realm/bindgen/src/templates/jsi.ts @@ -79,7 +79,17 @@ function pushRet(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(); @@ -906,6 +916,16 @@ class JsiCppDecls extends CppDecls { `, ) .join("\n")} + + // We are returning sentinel values for lists and dictionaries in the + // form of Symbol singletons. This is due to not being able to construct + // the actual list or dictionary in the current context. + case realm::type_List: + return ${this.addon.accessCtor("Symbol_for")}.call(_env, "Realm.List"); + + case realm::type_Dictionary: + return ${this.addon.accessCtor("Symbol_for")}.call(_env, "Realm.Dictionary"); + // The remaining cases are never stored in a Mixed. ${spec.mixedInfo.unusedDataTypes.map((t) => `case DataType::Type::${t}: break;`).join("\n")} } diff --git a/packages/realm/bindgen/src/templates/node.ts b/packages/realm/bindgen/src/templates/node.ts index f0ae972917..5dc5e90a1a 100644 --- a/packages/realm/bindgen/src/templates/node.ts +++ b/packages/realm/bindgen/src/templates/node.ts @@ -843,6 +843,16 @@ class NodeCppDecls extends CppDecls { `, ) .join("\n")} + + // We are returning sentinel values for lists and dictionaries in the + // form of Symbol singletons. This is due to not being able to construct + // the actual list or dictionary in the current context. + case realm::type_List: + return Napi::Symbol::For(napi_env_var_ForBindGen, "Realm.List"); + + case realm::type_Dictionary: + return Napi::Symbol::For(napi_env_var_ForBindGen, "Realm.Dictionary"); + // The remaining cases are never stored in a Mixed. ${spec.mixedInfo.unusedDataTypes.map((t) => `case DataType::Type::${t}: break;`).join("\n")} } diff --git a/packages/realm/bindgen/src/templates/typescript.ts b/packages/realm/bindgen/src/templates/typescript.ts index 8fa92c2abd..44de3541e4 100644 --- a/packages/realm/bindgen/src/templates/typescript.ts +++ b/packages/realm/bindgen/src/templates/typescript.ts @@ -124,7 +124,7 @@ function generateArguments(spec: BoundSpec, args: Arg[]) { function generateMixedTypes(spec: BoundSpec) { return ` - export type Mixed = null | ${spec.mixedInfo.getters + export type Mixed = null | symbol | ${spec.mixedInfo.getters .map(({ type }) => generateType(spec, type, Kind.Ret)) .join(" | ")}; export type MixedArg = null | ${spec.mixedInfo.ctors.map((type) => generateType(spec, type, Kind.Arg)).join(" | ")}; @@ -173,6 +173,8 @@ export function generate({ rawSpec, spec: boundSpec, file }: TemplateContext): v public reason?: string; constructor(isOk: boolean) { this.isOk = isOk; } } + export const ListSentinel = Symbol.for("Realm.List"); + export const DictionarySentinel = Symbol.for("Realm.Dictionary"); `); const out = file("native.d.ts", eslintFormatter); diff --git a/packages/realm/src/Collection.ts b/packages/realm/src/Collection.ts index 9a6bcd55a6..0bf447370e 100644 --- a/packages/realm/src/Collection.ts +++ b/packages/realm/src/Collection.ts @@ -16,11 +16,37 @@ // //////////////////////////////////////////////////////////////////////////// -import type { Dictionary, List, Results } from "./internal"; +import type { + Dictionary, + DictionaryAccessor, + List, + OrderedCollectionAccessor, + RealmSet, + Results, + TypeHelpers, +} from "./internal"; import { CallbackAdder, IllegalConstructorError, Listeners, TypeAssertionError, assert, binding } from "./internal"; /** - * Abstract base class containing methods shared by Realm {@link List}, {@link Dictionary} and {@link Results}. + * Collection accessor identifier. + * @internal + */ +export const COLLECTION_ACCESSOR = Symbol("Collection#accessor"); + +/** + * Collection type helpers identifier. + * @internal + */ +export const COLLECTION_TYPE_HELPERS = Symbol("Collection#typeHelpers"); + +/** + * Accessor for getting and setting items in the binding collection. + * @internal + */ +type CollectionAccessor = 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 649ef75192..9cc0eb181d 100644 --- a/packages/realm/src/Dictionary.ts +++ b/packages/realm/src/Dictionary.ts @@ -15,49 +15,55 @@ // limitations under the License. // //////////////////////////////////////////////////////////////////////////// + import { + COLLECTION_ACCESSOR as ACCESSOR, AssertionError, Collection, DefaultObject, IllegalConstructorError, JSONCacheMap, + List, Realm, RealmObject, + Results, + COLLECTION_TYPE_HELPERS as TYPE_HELPERS, TypeHelpers, assert, binding, + createListAccessor, + createResultsAccessor, + insertIntoListOfMixed, + isJsOrRealmList, + toItemType, } from "./internal"; /* eslint-disable jsdoc/multiline-blocks -- We need this to have @ts-expect-error located correctly in the .d.ts bundle */ const REALM = Symbol("Dictionary#realm"); const INTERNAL = Symbol("Dictionary#internal"); -const HELPERS = Symbol("Dictionary#helpers"); export type DictionaryChangeSet = { deletions: string[]; modifications: string[]; insertions: string[]; }; -export type DictionaryChangeCallback = (dictionary: Dictionary, changes: DictionaryChangeSet) => void; + +export type DictionaryChangeCallback = (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 23441bafc9..4a4dc39340 100644 --- a/packages/realm/src/GeoSpatial.ts +++ b/packages/realm/src/GeoSpatial.ts @@ -123,6 +123,24 @@ export type GeoBox = { topRight: GeoPoint; }; +/** @internal */ +export function isGeoCircle(value: object): value is GeoCircle { + return "center" in value && "distance" in value && typeof value.distance === "number"; +} + +/** @internal */ +export function isGeoBox(value: object): value is GeoBox { + return "bottomLeft" in value && "topRight" in value; +} + +/** @internal */ +export function isGeoPolygon(value: object): value is GeoPolygon { + return ( + ("type" in value && value.type === "Polygon" && "coordinates" in value && Array.isArray(value.coordinates)) || + ("outerRing" in value && Array.isArray(value.outerRing)) + ); +} + /** @internal */ export function circleToBindingGeospatial(circle: GeoCircle): binding.Geospatial { return binding.Geospatial.makeFromCircle({ diff --git a/packages/realm/src/List.ts b/packages/realm/src/List.ts index d37f3b7b36..2e4564e8b8 100644 --- a/packages/realm/src/List.ts +++ b/packages/realm/src/List.ts @@ -17,14 +17,21 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, AssertionError, + Dictionary, IllegalConstructorError, ObjectSchema, OrderedCollection, - OrderedCollectionHelpers, Realm, + TypeHelpers, assert, binding, + createDefaultGetter, + createDictionaryAccessor, + insertIntoDictionaryOfMixed, + isJsOrRealmDictionary, + toItemType, } from "./internal"; type PartiallyWriteableArray = 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 51135039f5..b2d9e0b0ea 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -29,14 +29,15 @@ import { ObjectListeners, OmittedRealmTypes, OrderedCollection, - OrderedCollectionHelpers, Realm, RealmObjectConstructor, Results, TypeAssertionError, + TypeHelpers, Unmanaged, assert, binding, + createResultsAccessor, flags, getTypeName, } from "./internal"; @@ -429,7 +430,8 @@ export class RealmObject(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); } /** @@ -574,6 +573,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]`. @@ -75,11 +91,6 @@ export type CollectionChangeCallback 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 @@ -97,13 +108,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; } } @@ -131,19 +148,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 { @@ -166,6 +203,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; @@ -182,12 +220,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. @@ -269,10 +293,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]; } } @@ -281,11 +304,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; } } @@ -310,7 +332,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 */ @@ -871,7 +903,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") { @@ -887,7 +919,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); } /** @internal */ @@ -932,3 +972,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 f60976c892..0db8ac1aa9 100644 --- a/packages/realm/src/PropertyHelpers.ts +++ b/packages/realm/src/PropertyHelpers.ts @@ -20,7 +20,7 @@ import { ClassHelpers, Dictionary, List, - OrderedCollectionHelpers, + ListAccessor, Realm, RealmSet, Results, @@ -29,7 +29,16 @@ import { TypeOptions, assert, binding, + createDictionaryAccessor, + createListAccessor, + createResultsAccessor, + createSetAccessor, getTypeHelpers, + insertIntoDictionaryOfMixed, + insertIntoListOfMixed, + isJsOrRealmDictionary, + isJsOrRealmList, + toItemType, } from "./internal"; type PropertyContext = binding.Property & { @@ -39,13 +48,6 @@ type PropertyContext = binding.Property & { default?: unknown; }; -function getObj(results: binding.Results, index: number) { - return results.getObj(index); -} -function getAny(results: binding.Results, index: number) { - return results.getAny(index); -} - /** @internal */ export type HelperOptions = { realm: Realm; @@ -60,15 +62,15 @@ type PropertyOptions = { } & HelperOptions & binding.Property_Relaxed; -type PropertyAccessors = { +type PropertyAccessor = { get(obj: binding.Obj): unknown; set(obj: binding.Obj, value: unknown): unknown; - collectionHelpers?: OrderedCollectionHelpers; + listAccessor?: ListAccessor; }; /** @internal */ export type PropertyHelpers = TypeHelpers & - PropertyAccessors & { + PropertyAccessor & { type: binding.PropertyType; columnKey: binding.ColKey; embedded: boolean; @@ -116,7 +118,7 @@ function embeddedSet({ typeHelpers: { toBinding }, columnKey }: PropertyOptions) }; } -type AccessorFactory = (options: PropertyOptions) => PropertyAccessors; +type AccessorFactory = (options: PropertyOptions) => PropertyAccessor; const ACCESSOR_FACTORIES: Partial> = { [binding.PropertyType.Object](options) { @@ -153,11 +155,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}`, @@ -167,13 +167,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"); @@ -186,71 +179,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}`, @@ -259,23 +232,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}"]`); @@ -287,7 +265,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}`, @@ -297,76 +275,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); } @@ -413,23 +379,12 @@ export function createPropertyHelpers(property: PropertyContext, options: Helper typeHelpers: getTypeHelpers(collectionType, typeOptions), }); } else { - const baseType = property.type & ~binding.PropertyType.Flags; - return getPropertyHelpers(baseType, { + const itemType = toItemType(property.type); + return getPropertyHelpers(itemType, { ...property, ...options, ...typeOptions, - typeHelpers: getTypeHelpers(baseType, typeOptions), + typeHelpers: getTypeHelpers(itemType, typeOptions), }); } } - -/** @internal */ -export function isPOJO(value: unknown): value is Record { - 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 1395c2ec33..2eed9db69d 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -48,10 +48,12 @@ import { SubscriptionSet, SyncSession, TypeAssertionError, + TypeHelpers, Unmanaged, UpdateMode, assert, binding, + createResultsAccessor, defaultLogger, defaultLoggerLevel, extendDebug, @@ -938,25 +940,27 @@ export class Realm { objects(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 962f3a776e..41e3560247 100644 --- a/packages/realm/src/Results.ts +++ b/packages/realm/src/Results.ts @@ -17,16 +17,22 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, + Dictionary, IllegalConstructorError, + List, OrderedCollection, - OrderedCollectionHelpers, Realm, SubscriptionOptions, TimeoutPromise, + TypeHelpers, Unmanaged, WaitForSync, assert, binding, + createDefaultGetter, + createDictionaryAccessor, + createListAccessor, } from "./internal"; /** @@ -38,12 +44,18 @@ import { * will thus never be called). * @see https://www.mongodb.com/docs/realm/sdk/react-native/model-data/data-types/collections/ */ -export class Results 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 37e1c8c1af..237dc3a51e 100644 --- a/packages/realm/src/Set.ts +++ b/packages/realm/src/Set.ts @@ -17,12 +17,15 @@ //////////////////////////////////////////////////////////////////////////// import { + COLLECTION_ACCESSOR as ACCESSOR, IllegalConstructorError, OrderedCollection, - OrderedCollectionHelpers, Realm, + COLLECTION_TYPE_HELPERS as TYPE_HELPERS, + TypeHelpers, assert, binding, + createDefaultGetter, } from "./internal"; /** @@ -39,16 +42,22 @@ import { * a user-supplied insertion order. * @see https://www.mongodb.com/docs/realm/sdk/react-native/model-data/data-types/sets/ */ -export class RealmSet 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 b1a02da03c..9de5f0d53e 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -21,13 +21,9 @@ import { ClassHelpers, Collection, Dictionary, - GeoBox, - GeoCircle, - GeoPolygon, INTERNAL, List, ObjCreator, - OrderedCollectionHelpers, REALM, Realm, RealmObject, @@ -38,6 +34,11 @@ import { binding, boxToBindingGeospatial, circleToBindingGeospatial, + createDictionaryAccessor, + createListAccessor, + isGeoBox, + isGeoCircle, + isGeoPolygon, polygonToBindingGeospatial, safeGlobalThis, } from "./internal"; @@ -73,7 +74,10 @@ export function toArrayBuffer(value: unknown, stringToBase64 = true) { return value; } -/** @internal */ +/** + * Helpers for converting a value to and from its binding representation. + * @internal + */ export type TypeHelpers = { toBinding( value: T, @@ -143,12 +147,14 @@ export function mixedToBinding( } } } + // Convert typed arrays to an `ArrayBuffer` for (const TypedArray of TYPED_ARRAY_CONSTRUCTORS) { if (value instanceof TypedArray) { return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); } } + // Rely on the binding for any other value return value as binding.MixedArg; } @@ -168,40 +174,23 @@ function mixedFromBinding(options: TypeOptions, value: binding.MixedArg): unknow const { wrapObject } = getClassHelpers(value.tableKey); return wrapObject(linkedObj); } else if (value instanceof binding.List) { - const collectionHelpers: OrderedCollectionHelpers = { - toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding: mixedFromBinding.bind(null, options), - get(_: binding.Results, index: number) { - return value.getAny(index); - }, - }; - return new List(realm, value, collectionHelpers); + const mixedType = binding.PropertyType.Mixed; + const typeHelpers = getTypeHelpers(mixedType, options); + return new List(realm, value, createListAccessor({ realm, typeHelpers, itemType: mixedType }), typeHelpers); } else if (value instanceof binding.Dictionary) { - const typeHelpers: TypeHelpers = { - toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding: mixedFromBinding.bind(null, options), - }; - return new Dictionary(realm, value, typeHelpers); + const mixedType = binding.PropertyType.Mixed; + const typeHelpers = getTypeHelpers(mixedType, options); + return new Dictionary( + realm, + value, + createDictionaryAccessor({ realm, typeHelpers, itemType: mixedType }), + typeHelpers, + ); } else { return value; } } -function isGeoCircle(value: object): value is GeoCircle { - return "distance" in value && "center" in value && typeof value["distance"] === "number"; -} - -function isGeoBox(value: object): value is GeoBox { - return "bottomLeft" in value && "topRight" in value; -} - -function isGeoPolygon(value: object): value is GeoPolygon { - return ( - ("type" in value && value["type"] === "Polygon" && "coordinates" in value && Array.isArray(value["coordinates"])) || - ("outerRing" in value && Array.isArray(value["outerRing"])) - ); -} - function defaultToBinding(value: unknown): binding.MixedArg { return value as binding.MixedArg; } @@ -356,9 +345,8 @@ const TYPES_MAPPING: Record Type }; }, [binding.PropertyType.Mixed](options) { - const { realm } = options; return { - toBinding: mixedToBinding.bind(null, realm.internal), + toBinding: mixedToBinding.bind(null, options.realm.internal), fromBinding: mixedFromBinding.bind(null, options), }; }, @@ -392,13 +380,14 @@ const TYPES_MAPPING: Record Type [binding.PropertyType.Array]({ realm, getClassHelpers, name, objectSchemaName }) { assert.string(objectSchemaName, "objectSchemaName"); const classHelpers = getClassHelpers(objectSchemaName); + return { fromBinding(value: unknown) { assert.instanceOf(value, binding.List); const propertyHelpers = classHelpers.properties.get(name); - const collectionHelpers = propertyHelpers.collectionHelpers; - assert.object(collectionHelpers); - return new List(realm, value, collectionHelpers); + const { listAccessor } = propertyHelpers; + assert.object(listAccessor); + return new List(realm, value, listAccessor, propertyHelpers); }, toBinding() { throw new Error("Not supported"); @@ -437,6 +426,10 @@ const TYPES_MAPPING: Record Type }; /** @internal */ +export function toItemType(type: binding.PropertyType) { + return type & ~binding.PropertyType.Flags; +} + export function getTypeHelpers(type: binding.PropertyType, options: TypeOptions): TypeHelpers { const helpers = TYPES_MAPPING[type]; assert(helpers, `Unexpected type ${type}`); diff --git a/packages/realm/src/tests/PropertyHelpers.test.ts b/packages/realm/src/tests/collection-helpers.test.ts similarity index 83% rename from packages/realm/src/tests/PropertyHelpers.test.ts rename to packages/realm/src/tests/collection-helpers.test.ts index 685a3784b7..b80cd2db43 100644 --- a/packages/realm/src/tests/PropertyHelpers.test.ts +++ b/packages/realm/src/tests/collection-helpers.test.ts @@ -18,9 +18,9 @@ import { expect } from "chai"; -import { isPOJO } from "../PropertyHelpers"; +import { isPOJO } from "../Dictionary"; -describe("PropertyHelpers", () => { +describe("Collection helpers", () => { describe("isPOJO()", () => { it("returns true for object literal", () => { const object = {}; @@ -53,12 +53,13 @@ describe("PropertyHelpers", () => { expect(isPOJO(object)).to.be.false; }); - it("returns false for user-defined class called Object", () => { - class Object {} - const object = new Object(); - expect(object.constructor).to.equal(Object); - expect(isPOJO(object)).to.be.false; - }); + // TS2725 compile error: "Class name cannot be 'Object' when targeting ES5 with module Node16" + // it("returns false for user-defined class called Object", () => { + // class Object {} + // const object = new Object(); + // expect(object.constructor).to.equal(Object); + // expect(isPOJO(object)).to.be.false; + // }); it("returns false for Arrays", () => { expect(isPOJO([])).to.be.false;