diff --git a/CHANGELOG.md b/CHANGELOG.md index 5820a32536..4d93a88194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,79 @@ +## vNext (TBD) + +### Enhancements +* A `mixed` value can now hold a `Realm.List` and `Realm.Dictionary` with nested collections. Note that `Realm.Set` is not supported as a `mixed` value. ([#6613](https://github.com/realm/realm-js/pull/6613)) +```typescript +class CustomObject extends Realm.Object { + value!: Realm.Types.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: [ + { + string: "world", + }, + ], + }, + }); +}); + +// Accessing the collection value returns the managed collection. +const dictionary = realmObject.value; +expectDictionary(dictionary); +const list = dictionary.list; +expectList(list); +const leafDictionary = list[0]; +expectDictionary(leafDictionary); +console.log(leafDictionary.string); // "world" + +// Update the Mixed property to a list. +realm.write(() => { + realmObject.value = [1, "hello", { newKey: "new value" }]; +}); + +// Useful custom helper functions. (Will be provided in a future release.) +function expectList(value: unknown): asserts value is Realm.List { + if (!(value instanceof Realm.List)) { + throw new Error("Expected a 'Realm.List'."); + } +} +function expectDictionary(value: unknown): asserts value is Realm.Dictionary { + if (!(value instanceof Realm.Dictionary)) { + throw new Error("Expected a 'Realm.Dictionary'."); + } +} +``` + +### Fixed +* ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) +* None + +### Compatibility +* React Native >= v0.71.4 +* Realm Studio v15.0.0. +* File format: generates Realms with format v24 (reads and upgrades file format v10 or later). + +### Internal + + + + ## 12.7.1 (2024-04-19) ### Fixed diff --git a/integration-tests/tests/src/hooks/open-realm-before.ts b/integration-tests/tests/src/hooks/open-realm-before.ts index 4bc9627db7..3e91067490 100644 --- a/integration-tests/tests/src/hooks/open-realm-before.ts +++ b/integration-tests/tests/src/hooks/open-realm-before.ts @@ -69,9 +69,10 @@ export function openRealmHook(config: OpenRealmConfiguration = {}) { export function closeThisRealm(this: RealmContext & Mocha.Context): void { if (this.closeRealm) { this.closeRealm({ clearTestState: true, deleteFile: true }); + } else { + // Clearing the test state to ensure the sync session gets completely reset and nothing is cached between tests + Realm.clearTestState(); } - // Clearing the test state to ensure the sync session gets completely reset and nothing is cached between tests - Realm.clearTestState(); } export function openRealmBeforeEach(config: OpenRealmConfiguration = {}): void { 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..d2b32e3306 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: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). + // obj.arrayCol = obj.arrayCol; // eslint-disable-line no-self-assign + // expect(obj.arrayCol[0].doubleCol).equals(1.0); //@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 115108c826..fe7f74264a 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -16,9 +16,10 @@ // //////////////////////////////////////////////////////////////////////////// -import Realm, { BSON } from "realm"; +import Realm, { BSON, ObjectSchema } from "realm"; import { expect } from "chai"; -import { openRealmBefore } from "../hooks"; + +import { openRealmBefore, openRealmBeforeEach } from "../hooks"; interface ISingle { a: Realm.Mixed; @@ -46,10 +47,26 @@ interface IMixedNullable { } interface IMixedSchema { - value: Realm.Mixed; + mixed: Realm.Mixed; +} + +interface IMixedAndEmbedded { + mixed: Realm.Mixed; + embeddedObject: { mixed: Realm.Mixed }; +} + +interface IMixedWithDefaultCollections { + mixedWithDefaultList: Realm.Mixed; + mixedWithDefaultDictionary: Realm.Mixed; +} + +interface ICollectionsOfMixed { + list: Realm.List; + dictionary: Realm.Dictionary; + set: Realm.Set; } -const SingleSchema: Realm.ObjectSchema = { +const SingleSchema: ObjectSchema = { name: "mixed", properties: { a: "mixed", @@ -59,7 +76,7 @@ const SingleSchema: Realm.ObjectSchema = { }, }; -const VertexSchema: Realm.ObjectSchema = { +const VertexSchema: ObjectSchema = { name: "Vertex", properties: { a: "int", @@ -68,7 +85,7 @@ const VertexSchema: Realm.ObjectSchema = { }, }; -const MixNestedSchema: Realm.ObjectSchema = { +const MixNestedSchema: ObjectSchema = { name: "Nested", properties: { a: "mixed", @@ -77,7 +94,7 @@ const MixNestedSchema: Realm.ObjectSchema = { }, }; -const MixedNullableSchema: Realm.ObjectSchema = { +const MixedNullableSchema: ObjectSchema = { name: "mixed", properties: { nullable: "mixed", @@ -85,9 +102,93 @@ const MixedNullableSchema: Realm.ObjectSchema = { }, }; -const MixedSchema: Realm.ObjectSchema = { +const MixedSchema: ObjectSchema = { name: "MixedClass", - properties: { value: "mixed" }, + properties: { + mixed: "mixed", + }, +}; + +const MixedAndEmbeddedSchema: ObjectSchema = { + name: "MixedAndEmbedded", + properties: { + mixed: "mixed", + embeddedObject: "EmbeddedObject?", + }, +}; + +const EmbeddedObjectSchema: ObjectSchema = { + name: "EmbeddedObject", + embedded: true, + properties: { + mixed: "mixed", + }, +}; + +const CollectionsOfMixedSchema: ObjectSchema = { + name: "CollectionsOfMixed", + properties: { + list: "mixed[]", + dictionary: "mixed{}", + set: "mixed<>", + }, +}; + +const bool = true; +const int = BigInt(123); +const double = 123.456; +const d128 = BSON.Decimal128.fromString("6.022e23"); +const string = "hello"; +const date = new Date(); +const oid = new BSON.ObjectId(); +const uuid = new BSON.UUID(); +const nullValue = null; +const uint8Values = [0, 1, 2, 4, 8]; +const uint8Buffer = new Uint8Array(uint8Values).buffer; +// 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 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, + d128, + string, + date, + oid, + uuid, + nullValue, + uint8Buffer, +}; + +const MixedWithDefaultCollectionsSchema: ObjectSchema = { + name: "MixedWithDefaultCollections", + properties: { + mixedWithDefaultList: { type: "mixed", default: [...primitiveTypesList] }, + mixedWithDefaultDictionary: { type: "mixed", default: { ...primitiveTypesDictionary } }, + }, }; describe("Mixed", () => { @@ -136,6 +237,8 @@ describe("Mixed", () => { const oid = new BSON.ObjectId(); const uuid = new BSON.UUID(); const date = new Date(); + const list = [1, "two"]; + const dictionary = { number: 1, string: "two" }; const data = this.realm.write(() => this.realm.create(SingleSchema.name, { a: oid })); expect(typeof data.a === typeof oid, "should be the same type BSON.ObjectId"); @@ -145,27 +248,37 @@ describe("Mixed", () => { expect(typeof data.a === typeof uuid, "should be the same type BSON.UUID"); expect(String(data.a)).equals(uuid.toString(), "should have the same content BSON.UUID"); + this.realm.write(() => (data.a = date)); + expect(typeof data.a === typeof date, "should be the same type Date"); + expect(String(data.a)).equals(date.toString(), "should have the same content Date"); + this.realm.write(() => (data.a = d128)); expect(String(data.a)).equals(d128.toString(), "Should be the same BSON.Decimal128"); this.realm.write(() => (data.a = 12345678)); expect(data.a).equals(12345678, "Should be the same 12345678"); - this.realm.write(() => ((data.a = null), "Should be the same null")); + this.realm.write(() => (data.a = null)); expect(data.a).equals(null); - this.realm.write(() => ((data.a = undefined), "Should be the same null")); + this.realm.write(() => (data.a = undefined)); expect(data.a).equals(null); - }); - it("wrong type throws", function (this: RealmContext) { - expect(() => { - this.realm.write(() => this.realm.create(SingleSchema.name, { a: Object.create({}) })); - }).throws(Error, "Unable to convert an object with ctor 'Object' to a Mixed"); + + this.realm.write(() => (data.a = list)); + expect(data.a).to.be.instanceOf(Realm.List); + expect((data.a as Realm.List)[0]).equals(1); + + this.realm.write(() => (data.a = dictionary)); + expect(data.a).to.be.instanceOf(Realm.Dictionary); + expect((data.a as Realm.Dictionary).number).equals(1); }); }); describe("Nested types", () => { - openRealmBefore({ schema: [SingleSchema, VertexSchema, MixNestedSchema] }); + openRealmBefore({ + schema: [SingleSchema, VertexSchema, MixNestedSchema, MixedAndEmbeddedSchema, EmbeddedObjectSchema], + }); + it("support nested types", function (this: RealmContext) { const obj1 = this.realm.write(() => { const r = this.realm.create(VertexSchema.name, { a: 1, b: 0, c: 0 }); @@ -199,6 +312,28 @@ describe("Mixed", () => { expect((obj2.b as IVertex).a).equals(1, "Should be equal 1"); expect((obj2.b as IVertex).b).equals(0, "Should be equal 0"); }); + + it("throws if nested type is an embedded object", function (this: RealmContext) { + // 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.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); + expect(objects.length).equals(1); + expect(objects[0].mixed).to.be.null; + }); }); describe("Nullable types", () => { @@ -220,41 +355,2593 @@ describe("Mixed", () => { expect(value.nullable_list[4]).equals(5, "Should be equal 5"); }); }); - describe("Mixed arrays", () => { - openRealmBefore({ schema: [MixedSchema] }); - it("throws when creating an array of multiple values", function (this: RealmContext) { - const objectsBefore = this.realm.objects(MixedSchema.name); - expect(objectsBefore.length).equals(0); - // check if the understandable error message is thrown - expect(() => { - this.realm.write(() => { - this.realm.create("MixedClass", { value: [123, false, "hello"] }); + describe("Collection types", () => { + openRealmBeforeEach({ + schema: [ + MixedSchema, + MixedAndEmbeddedSchema, + MixedWithDefaultCollectionsSchema, + CollectionsOfMixedSchema, + EmbeddedObjectSchema, + ], + }); + + 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 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 collection) { + if (item instanceof Realm.Object) { + // @ts-expect-error Expecting `mixed` to exist. + expect(item.mixed).equals(unmanagedRealmObject.mixed); + } else if (item instanceof ArrayBuffer) { + expectUint8Buffer(item); + } else if (item instanceof Realm.List) { + expectListOfAllTypes(item); + } else if (item instanceof Realm.Dictionary) { + expectDictionaryOfAllTypes(item); + } else { + expect(String(item)).equals(String(primitiveTypesList[index])); + } + index++; + } + } + + /** + * 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)).to.include.members(Object.keys(primitiveTypesDictionary)); + + for (const key in dictionary) { + const value = dictionary[key]; + if (key === "realmObject") { + expect(value).instanceOf(Realm.Object); + // @ts-expect-error Expecting `mixed` to exist. + expect(value.mixed).equals(unmanagedRealmObject.mixed); + } else if (key === "uint8Buffer") { + expectUint8Buffer(value); + } else if (key === "list") { + expectListOfAllTypes(value); + } else if (key === "dictionary") { + expectDictionaryOfAllTypes(value); + } else { + expect(String(value)).equals(String(primitiveTypesDictionary[key])); + } + } + } + + /** + * 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); + } + + /** + * 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); + 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); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); + }); + + 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 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. + 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); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(2); + expectListOfAllTypes(list1); + expectListOfAllTypes(list2); + }); + + 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); + 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("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 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, + }); + + 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; + }); }); - }).throws(Error, "A mixed property cannot contain an array of values."); - // verify that the transaction has been rolled back - const objectsAfter = this.realm.objects(MixedSchema.name); - expect(objectsAfter.length).equals(0); - }); + 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, + }); + + 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: 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 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. + 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); + expect(this.realm.objects(CollectionsOfMixedSchema.name).length).equals(2); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + 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); + expectDictionaryOfAllTypes(mixedWithDefaultDictionary); + }); + + 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: { mixed: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Spread the embedded object in order to use its entries as a dictionary in Mixed. + const dictionary1 = this.realm.create(MixedSchema.name, { + mixed: { ...embeddedObject }, + }).mixed; + const dictionary2 = this.realm.create(CollectionsOfMixedSchema.name, { + dictionary: { ...embeddedObject }, + }).dictionary; + + return { dictionary1, dictionary2 }; + }); + + 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("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 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 }; + }); + + 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("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]] }; + + 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); + expectDictionaryOfListsOfAllTypes(dictionary1); + expectDictionaryOfListsOfAllTypes(dictionary2); + }); + + 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 } } }; + + 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); + expectDictionaryOfDictionariesOfAllTypes(dictionary1); + expectDictionaryOfDictionariesOfAllTypes(dictionary2); + }); + + 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 }; + }); + + 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 }; + }); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); + + this.realm.write(() => { + 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; + }); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + 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; + + return { dictionary1, dictionary2 }; + }); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); + + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedList = [[...primitiveTypesList, realmObject]]; + + dictionary1.depth1 = unmanagedList; + dictionary2.depth1 = unmanagedList; + }); + expectDictionaryOfListsOfAllTypes(dictionary1); + expectDictionaryOfListsOfAllTypes(dictionary2); + }); + + 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 }; + }); + expectRealmDictionary(dictionary1); + expectRealmDictionary(dictionary2); + expect(Object.keys(dictionary1).length).equals(0); + expect(Object.keys(dictionary2).length).equals(0); + + this.realm.write(() => { + const realmObject = this.realm.create(MixedSchema.name, unmanagedRealmObject); + const unmanagedDictionary = { depth2: { ...primitiveTypesDictionary, realmObject } }; + + dictionary1.depth1 = unmanagedDictionary; + dictionary2.depth1 = unmanagedDictionary; + }); + expectDictionaryOfDictionariesOfAllTypes(dictionary1); + expectDictionaryOfDictionariesOfAllTypes(dictionary2); + }); + + 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(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(() => { + for (const key in unmanagedDictionary) { + const value = unmanagedDictionary[key]; + dictionary1[key] = value; + dictionary2[key] = value; + } + }); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + 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; + + return { dictionary1, dictionary2 }; + }); + 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(() => { + dictionary1.set(unmanagedDictionary); + dictionary2.set(unmanagedDictionary); + }); + expectDictionaryOfAllTypes(dictionary1); + expectDictionaryOfAllTypes(dictionary2); + }); + + 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, + }); + + 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); + + 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; + + 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; + }); + }); + + 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()); + }); + + 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()); + }); + }); + + 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 results = this.realm.objects(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(2); + + const list = results[1].mixed; + expectListOfAllTypes(list); + }); + + 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 }); + }); + + const results = this.realm.objects(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(1); + + const list = results[0].mixed; + expectListOfAllTypes(list); + }); + }); + }); + + 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); + }); + + 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 }); + }); + + const results = this.realm.objects(MixedSchema.name); + expectRealmResults(results); + expect(results.length).equals(1); + + const dictionary = results[0].mixed; + expectDictionaryOfAllTypes(dictionary); + }); + }); + }); + }); + }); + + 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"); + + this.realm.write(() => { + list[0] = "updated"; + }); + expect(list.length).equals(1); + expect(list[0]).equals("updated"); + + this.realm.write(() => { + list[0] = null; + }); + expect(list.length).equals(1); + expect(list[0]).to.be.null; + + this.realm.write(() => { + list[0] = [[...primitiveTypesList, realmObject]]; + }); + expectListOfListsOfAllTypes(list); + + 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: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). + it.skip("self assigns", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create(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: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). + it.skip("self assigns nested list", function (this: RealmContext) { + const { mixed: list } = this.realm.write(() => { + return this.realm.create(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: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). + it.skip("self assigns", function (this: RealmContext) { + const created = this.realm.write(() => { + return this.realm.create(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: Enable when self-assignment is solved (https://github.com/realm/realm-core/issues/7422). + it.skip("self assigns nested dictionary", function (this: RealmContext) { + const { mixed: dictionary } = this.realm.write(() => { + return this.realm.create(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); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[${nonExistentIndex}][*] == $0`, itemToMatch); + // expect(filtered.length).equals(0); + + 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); + }); + + 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(() => { + // 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); + 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) + let filtered = objects.filtered(`mixed[0][0][${index}] == $0`, itemToMatch); + // expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][${index}] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns 2 objects -- the objects whose mixed fields are strings.) + // filtered = objects.filtered(`mixed[0][0][${nonExistentIndex}] == $0`, itemToMatch); + // expect(filtered.length).equals(0); + + // Objects with a nested list item that matches the `itemToMatch` at ANY index. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[0][0][*] == $0`, itemToMatch); + // expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed[0][0][*] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `itemToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed[0][${nonExistentIndex}][*] == $0`, itemToMatch); + // expect(filtered.length).equals(0); + + 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (This returns all objects.) + // filtered = objects.filtered(`mixed[0][0][*].@type == 'null'`); + // expect(filtered.length).equals(expectedFilteredCount); + + 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("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(() => { + // 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (For all keys, this returns 0 objects.) + // filtered = objects.filtered(`mixed.${key} IN $0`, insertedValues); + // expect(filtered.length).equals(expectedFilteredCount); + + 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); + 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) + let filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, valueToMatch); + // expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed['depth1']['depth2']['${key}'] == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed['depth1']['depth2']['${nonExistentKey}'] == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, valueToMatch); + // expect(filtered.length).equals(expectedFilteredCount); + + filtered = objects.filtered(`mixed.depth1.depth2.${key} == $0`, nonExistentValue); + expect(filtered.length).equals(0); + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2.${nonExistentKey} == $0`, valueToMatch); + // Core treats missing keys as `null` in queries. + // expect(filtered.length).equals(valueToMatch === null ? expectedFilteredCount : 0); + + // Objects with a nested dictionary value that matches the `valueToMatch` at ANY key. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (When `valueToMatch` is `null`, this returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2[*] == $0`, valueToMatch); + // expect(filtered.length).equals(expectedFilteredCount); + + 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (For all keys, this returns 2 objects -- the objects whose mixed fields are strings.) + // filtered = objects.filtered(`mixed.depth1.depth2.${key} IN $0`, insertedValues); + // expect(filtered.length).equals(expectedFilteredCount); + + 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. + + // TODO: Enable after https://github.com/realm/realm-core/issues/7587. (This returns all objects.) + // filtered = objects.filtered(`mixed.depth1.depth2[*].@type == 'null'`); + // expect(filtered.length).equals(expectedFilteredCount); + + 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 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"); + + this.realm.write(() => { + expect(() => (list[0] = new Set())).to.throw("Using a Set as a Mixed value is not supported"); + expect(() => (list[0] = set)).to.throw("Using a RealmSet as a Mixed value is not supported"); + }); + expect(list[0]).equals("original"); + }); + + it("throws when updating a dictionary entry to a set", function (this: RealmContext) { + const { set, dictionary } = this.realm.write(() => { + const 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.key).equals("original"); + + this.realm.write(() => { + 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.key).equals("original"); + }); + + it("throws when creating a list or dictionary with an embedded object", function (this: RealmContext) { + // 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); + const objects = this.realm.objects(MixedAndEmbeddedSchema.name); + 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: { mixed: 1 }, + }); + expect(embeddedObject).instanceOf(Realm.Object); + + // Create an object with the Mixed property as a list. + const { mixed: list } = this.realm.create(MixedAndEmbeddedSchema.name, { + mixed: ["original"], + }); + expectRealmList(list); + + // 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.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 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, { mixed: "original" }); + }); + expect(created.mixed).equals("original"); + expect(() => (created.mixed = ["a list item"])).to.throw( + "Cannot modify managed objects outside of a write transaction", + ); + expect(() => (created.mixed = { key: "a dictionary value" })).to.throw( + "Cannot modify managed objects outside of a write transaction", + ); + 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, { mixed: [1] }); + }); + const list = created.mixed; + expectRealmList(list); + + this.realm.write(() => { + created.mixed = 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, { mixed: { key: "original" } }); + }); + const dictionary = created.mixed; + expectRealmDictionary(dictionary); + + this.realm.write(() => { + created.mixed = null; + }); + expect(created.mixed).to.be.null; + expect(() => dictionary.key).to.throw("This collection is no more"); + }); + }); + }); + + describe("Typed arrays in Mixed", () => { + openRealmBeforeEach({ schema: [MixedSchema] }); + it("supports datatypes with binary data contents", function (this: RealmContext) { const uint8Values1 = [0, 1, 2, 4, 8]; const uint8Values2 = [255, 128, 64, 32, 16, 8]; 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(() => { @@ -263,10 +2950,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); @@ -278,11 +2965,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(() => { @@ -293,11 +2980,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 7c61efc765..37777d2f00 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -24,7 +24,7 @@ import { assert, expect } from "chai"; -import Realm, { CollectionChangeSet, DictionaryChangeSet, ObjectChangeSet, RealmEventName } from "realm"; +import Realm, { CollectionChangeSet, DictionaryChangeSet, ObjectChangeSet, ObjectSchema, RealmEventName } from "realm"; import { openRealmBeforeEach } from "../hooks"; import { createListenerStub } from "../utils/listener-stub"; @@ -186,8 +186,8 @@ async function expectCollectionNotifications( await expectNotifications( (listener) => collection.addListener(listener, keyPaths), (listener) => collection.removeListener(listener), - (expectedChange, c) => (_: Realm.Collection, actualChange: CollectionChangeSet) => { - expect(actualChange).deep.equals(expectedChange, `Changeset #${c} didn't match`); + (expectedChanges, c) => (_: Realm.Collection, actualChanges: CollectionChangeSet) => { + expect(actualChanges).deep.equals(expectedChanges, `Changeset #${c} didn't match`); }, changesAndActions, ); @@ -205,8 +205,11 @@ async function expectDictionaryNotifications( await expectNotifications( (listener) => dictionary.addListener(listener, keyPaths), (listener) => dictionary.removeListener(listener), - (expectedChange, c) => (_: Realm.Dictionary, actualChange: DictionaryChangeSet) => { - expect(actualChange).deep.equals(expectedChange, `Changeset #${c} didn't match`); + (expectedChanges, c) => (_: Realm.Dictionary, actualChanges: DictionaryChangeSet) => { + const errorMessage = `Changeset #${c} didn't match`; + expect(actualChanges.insertions).members(expectedChanges.insertions, errorMessage); + expect(actualChanges.modifications).members(expectedChanges.modifications, errorMessage); + expect(actualChanges.deletions).members(expectedChanges.deletions, errorMessage); }, changesAndActions, ); @@ -235,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 */ } @@ -1373,4 +1384,579 @@ describe("Observable", () => { }); }); }); + + describe("Collections in Mixed", () => { + class ObjectWithMixed extends Realm.Object { + mixed!: Realm.Types.Mixed; + + static schema: ObjectSchema = { + name: "ObjectWithMixed", + properties: { + mixed: "mixed", + }, + }; + } + + type CollectionsInMixedContext = RealmContext & { + objectWithList: Realm.Object & ObjectWithMixed; + objectWithDictionary: Realm.Object & ObjectWithMixed; + }; + + openRealmBeforeEach({ schema: [ObjectWithMixed] }); + + beforeEach(function (this: CollectionsInMixedContext) { + this.objectWithList = this.realm.write(() => this.realm.create(ObjectWithMixed, { mixed: [] })); + this.objectWithDictionary = this.realm.write(() => this.realm.create(ObjectWithMixed, { mixed: {} })); + }); + + describe("Collection notifications", () => { + describe("List", () => { + it("fires when inserting, updating, and deleting at top-level", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + 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 inserting, updating, and deleting in nested list", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + 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"); + }, + ]); + }); + }); + + 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"); + }, + ]); + }); + }); + }); + + 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(() => { + list.push("Amy"); + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Update list item. + () => { + this.realm.write(() => { + list[0] = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Delete list item. + () => { + this.realm.write(() => { + list.remove(0); + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + ]); + }); + + it("fires when inserting, updating, and deleting in nested list", async function (this: CollectionsInMixedContext) { + const list = this.objectWithList.mixed; + expectRealmList(list); + + await expectObjectNotifications(this.objectWithList, undefined, [ + EMPTY_OBJECT_CHANGESET, + // Insert nested list. + () => { + this.realm.write(() => { + list.push([]); + }); + expectRealmList(list[0]); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Insert item into nested list. + () => { + this.realm.write(() => { + list[0].push("Amy"); + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Update item in nested list. + () => { + this.realm.write(() => { + list[0][0] = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Delete item from nested list. + () => { + this.realm.write(() => { + list[0].remove(0); + }); + }, + { 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(() => { + dictionary.amy = "Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Update dictionary item. + () => { + this.realm.write(() => { + dictionary.amy = "Updated Amy"; + }); + }, + { deleted: false, changedProperties: ["mixed"] }, + // Delete dictionary item. + () => { + this.realm.write(() => { + dictionary.remove("amy"); + }); + }, + { 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/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 1e38a2c85a..30f6f77d54 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -15,29 +15,180 @@ // limitations under the License. // //////////////////////////////////////////////////////////////////////////// + import { expect } from "chai"; -import Realm from "realm"; +import Realm, { BSON, Configuration, Mixed, ObjectSchema } from "realm"; import { importAppBefore, authenticateUserBefore, openRealmBefore } from "../../hooks"; - import { itUploadsDeletesAndDownloads } from "./upload-delete-download"; import { buildAppConfig } from "../../utils/build-app-config"; +import { OpenRealmConfiguration, openRealm } from "../../utils/open-realm"; + +/** + * A function type that generates values to inserted and expected values used with the default tester. + * The input realm is necessary to create and add objects to the realm before using them in the tests. + * The distinction between "values" and "expected" is necessary because most tests here close the realm after using + * the objects in "values", so they can't be used with the default tester (that happens at a later time). + * "expected" contains just an object instead of a RealmObject that can be used for testing. + */ +type ValueAndExpectedGenerator = (realm: Realm) => { values: Mixed; expected: Mixed }; + +type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed) | ValueAndExpectedGenerator; +type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void; -type MixedClass = { - _id: Realm.BSON.ObjectId; +class MixedClass extends Realm.Object { + _id!: Realm.BSON.ObjectId; value: Realm.Mixed; - list: Realm.List; -}; -type Value = Realm.Mixed | ((realm: Realm) => Realm.Mixed); -type ValueTester = (actual: Realm.Mixed, inserted: Realm.Mixed) => void | boolean; + list!: Realm.List; + + static schema: ObjectSchema = { + name: "MixedClass", + properties: { + _id: "objectId", + value: "mixed", + list: "mixed[]", + }, + primaryKey: "_id", + }; +} + +const bool = true; +const int = 1; +const double = 123.456; +const d128 = BSON.Decimal128.fromString("6.022e23"); +const string = "hello"; +const date = new Date(); +const oid = new BSON.ObjectId(); +const uuid = new BSON.UUID(); +const nullValue = null; +const data = new Uint8Array([0xd8, 0x21, 0xd6, 0xe8, 0x00, 0x57, 0xbc, 0xb2, 0x6a, 0x15]).buffer; + +function getInnerObj(): any { + return { _id: new BSON.ObjectId() }; +} + +function getMixedList(realm: Realm) { + const expectedObj = getInnerObj(); + const obj = realm.create(MixedClass, expectedObj); + + const values = [bool, int, double, d128, string, oid, uuid, nullValue, date, data, obj]; + const expected = [bool, int, double, d128, string, oid, uuid, nullValue, date, data, expectedObj]; + + return { values, expected }; +} + +function getMixedDict(realm: Realm) { + const expectedObj = getInnerObj(); + const obj = realm.create(MixedClass, expectedObj); + + const values = { + bool, + int, + double, + d128, + string, + oid, + uuid, + nullValue, + date, + data, + obj, + }; + + const expected = { + bool, + int, + double, + d128, + string, + oid, + uuid, + nullValue, + date, + data, + obj: expectedObj, + }; + + return { values, expected }; +} + +function getNestedMixedList(realm: Realm) { + const mixList1 = getMixedList(realm); + const mixList2 = getMixedList(realm); + const mixDict = getMixedDict(realm); + + const values = [...mixList1.values, mixList2.values, mixDict.values]; + const expected = [...mixList1.expected, mixList2.expected, mixDict.expected]; + + return { values, expected }; +} + +function getNestedMixedDict(realm: Realm) { + const mixDict1 = getMixedDict(realm); + const mixDict2 = getMixedDict(realm); + const mixList = getMixedList(realm); + + const values = { + ...mixDict1.values, + innerDict: mixDict2.values, + innerList: mixList.values, + }; + + const expected = { + ...mixDict1.expected, + innerDict: mixDict2.expected, + innerList: mixList.expected, + }; + + return { values, expected }; +} + +function 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); +} /** * The default tester of values. * @param actual The value downloaded from the server. * @param inserted The value inserted locally before upload. */ -function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { - expect(actual).equals(inserted); +function defaultTester(actual: unknown, inserted: unknown) { + if (inserted instanceof Array) { + expectRealmList(actual); + expect(actual.length).equals(inserted.length); + inserted.forEach((item, index) => defaultTester(actual[index], item)); + } else if (inserted != null && typeof inserted === "object" && "d128" in inserted) { + expectRealmDictionary(actual); + const insertedKeys = Object.keys(actual); + const actualKeys = Object.keys(actual); + expect(insertedKeys).members(actualKeys); + insertedKeys.forEach((key) => defaultTester(actual[key], (inserted as Record)[key])); + } else if (inserted instanceof ArrayBuffer) { + const actualBinaryView = new Uint8Array(actual as ArrayBuffer); + const insertedBinaryView = new Uint8Array(inserted as ArrayBuffer); + expect(actualBinaryView.byteLength).equals(insertedBinaryView.byteLength); + insertedBinaryView.forEach((item, index) => defaultTester(item, actualBinaryView[index])); + } else if (inserted != null && typeof inserted === "object" && "_id" in inserted) { + expect(actual).instanceOf(MixedClass); + const actualMixed = actual as MixedClass; + const insertedMixed = inserted as MixedClass; + defaultTester(actualMixed._id, insertedMixed._id); + } else { + expect(String(actual)).equals(String(inserted)); + } +} + +async function setupIfFlexiblySync(realm: Realm, useFlexibleSync: boolean) { + if (useFlexibleSync) { + await realm.subscriptions.update((mutableSubs) => { + mutableSubs.add(realm.objects(MixedClass)); + }); + await realm.subscriptions.waitForSynchronization(); + } } /** @@ -48,69 +199,48 @@ function defaultTester(actual: Realm.Mixed, inserted: Realm.Mixed) { * - Deletes the Realm locally * - Reopens and downloads the Realm * - Performs a test to ensure the downloaded value match the value created locally. - * @param typeName - * @param options + * @param typeName Name of the mixed type (only used for the test name) + * @param value The value to be used for the test, or a function to obtain it + * @param valueTester The function used to assert equality + * @param useFlexibleSync Whether to use flexible sync (otherwise partition based sync will be used) */ function describeRoundtrip({ typeName, value, - testValue = defaultTester, - flexibleSync, + valueTester = defaultTester, + useFlexibleSync, }: { typeName: string; value: Value; - testValue?: ValueTester; - flexibleSync: boolean; + valueTester?: ValueTester; + useFlexibleSync: boolean; }) { function performTest(actual: Realm.Mixed, inserted: Realm.Mixed) { - const result = testValue(actual, inserted); - if (typeof result === "boolean") { - expect(result).equals(true, `${testValue} failed!`); - } - } - - // TODO: This might be a useful utility - function log(...args: [string]) { - const date = new Date(); - console.log(date.toString(), date.getMilliseconds(), ...args); - } - - async function setupTest(realm: Realm) { - if (flexibleSync) { - await realm.subscriptions.update((mutableSubs) => { - mutableSubs.add(realm.objects("MixedClass")); - }); - await realm.subscriptions.waitForSynchronization(); - } + valueTester(actual, inserted); } describe(`roundtrip of '${typeName}'`, () => { openRealmBefore({ - schema: [ - { - name: "MixedClass", - primaryKey: "_id", - properties: { - _id: "objectId", - value: "mixed?", - list: "mixed[]", - }, - }, - ], - sync: flexibleSync - ? { - flexible: true, - } - : { partitionValue: "mixed-test" }, + schema: [MixedClass], + sync: useFlexibleSync ? { flexible: true } : { partitionValue: "mixed-test" }, }); it("writes", async function (this: RealmContext) { - await setupTest(this.realm); - + await setupIfFlexiblySync(this.realm, useFlexibleSync); this._id = new Realm.BSON.ObjectId(); this.realm.write(() => { - this.value = typeof value === "function" ? value(this.realm) : value; - this.realm.create("MixedClass", { + if (typeof value === "function") { + const valueResult = value(this.realm); + if ("expected" in valueResult && "values" in valueResult) { + this.value = valueResult.values; + this.expected = valueResult.expected; + } else { + this.value = valueResult; + } + } else { + this.value = value; + } + this.realm.create(MixedClass, { _id: this._id, value: this.value, // Adding a few other unrelated elements to the list @@ -122,11 +252,11 @@ function describeRoundtrip({ itUploadsDeletesAndDownloads(); it("reads", async function (this: RealmContext) { - await setupTest(this.realm); + await setupIfFlexiblySync(this.realm, useFlexibleSync); const obj = await new Promise((resolve) => { this.realm - .objects("MixedClass") + .objects(MixedClass) .filtered("_id = $0", this._id) .addListener(([obj]) => { if (obj) { @@ -136,12 +266,14 @@ function describeRoundtrip({ }); expect(typeof obj).equals("object"); + + const testVal = this.expected === undefined ? this.value : this.expected; // Test the single value - performTest(obj.value, this.value); + performTest(obj.value, testVal); // Test the list of values expect(obj.list.length).equals(4); const firstElement = obj.list[0]; - performTest(firstElement, this.value); + performTest(firstElement, testVal); // No need to keep these around delete this._id; delete this.value; @@ -149,66 +281,53 @@ function describeRoundtrip({ }); } -function describeTypes(flexibleSync: boolean) { +function describeTypes(useFlexibleSync: boolean) { authenticateUserBefore(); - describeRoundtrip({ typeName: "null", value: null, flexibleSync }); + describeRoundtrip({ typeName: "null", value: null, useFlexibleSync }); - // TODO: Provide an API to speficy storing this as an int - describeRoundtrip({ typeName: "int", value: 123, flexibleSync }); + // TODO: Provide an API to specify storing this as an int + describeRoundtrip({ typeName: "int", value: 123, useFlexibleSync }); // TODO: Provide an API to specify which of these to store - describeRoundtrip({ typeName: "float / double", value: 123.456, flexibleSync }); - - describeRoundtrip({ typeName: "bool (true)", value: true, flexibleSync }); - describeRoundtrip({ typeName: "bool (false)", value: false, flexibleSync }); - - describeRoundtrip({ typeName: "string", value: "test-string", flexibleSync }); - - // Unsupported: - // describeSimpleRoundtrip("undefined", undefined); + describeRoundtrip({ typeName: "float / double", value: 123.456, useFlexibleSync }); + describeRoundtrip({ typeName: "bool (true)", value: true, useFlexibleSync }); + describeRoundtrip({ typeName: "bool (false)", value: false, useFlexibleSync }); + describeRoundtrip({ typeName: "string", value: "test-string", useFlexibleSync }); const buffer = new Uint8Array([4, 8, 12, 16]).buffer; describeRoundtrip({ typeName: "data", value: buffer, - testValue: (value: ArrayBuffer) => { - expect(value.byteLength).equals(4); - expect([...new Uint8Array(value)]).deep.equals([4, 8, 12, 16]); - }, - flexibleSync, + useFlexibleSync, }); const date = new Date(1620768552979); describeRoundtrip({ typeName: "date", value: date, - testValue: (value: Date) => value.getTime() === date.getTime(), - flexibleSync, + useFlexibleSync, }); const objectId = new Realm.BSON.ObjectId("609afc1290a3c1818f04635e"); describeRoundtrip({ typeName: "ObjectId", value: objectId, - testValue: (value: Realm.BSON.ObjectId) => objectId.equals(value), - flexibleSync, + useFlexibleSync, }); const uuid = new Realm.BSON.UUID("9476a497-60ef-4439-bc8a-52b8ad0d4875"); describeRoundtrip({ typeName: "UUID", value: uuid, - testValue: (value: Realm.BSON.UUID) => uuid.equals(value), - flexibleSync, + useFlexibleSync, }); const decimal128 = Realm.BSON.Decimal128.fromString("1234.5678"); describeRoundtrip({ typeName: "Decimal128", value: decimal128, - testValue: (value: Realm.BSON.Decimal128) => decimal128.bytes.equals(value.bytes), - flexibleSync, + useFlexibleSync, }); const recursiveObjectId = new Realm.BSON.ObjectId(); @@ -224,12 +343,42 @@ function describeTypes(flexibleSync: boolean) { result.value = result; return result; }, - testValue: (value: MixedClass) => recursiveObjectId.equals(value._id), - flexibleSync, + valueTester: (value: MixedClass) => { + expect(recursiveObjectId.equals(value._id)).to.be.true; + }, + useFlexibleSync, }); + + if (useFlexibleSync) { + describe("collections in mixed", () => { + describeRoundtrip({ + typeName: "list", + value: getMixedList, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "nested list", + value: getNestedMixedList, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "dictionary", + value: getMixedDict, + useFlexibleSync: true, + }); + + describeRoundtrip({ + typeName: "nested dictionary", + value: getNestedMixedDict, + useFlexibleSync: true, + }); + }); + } } -describe("mixed", () => { +describe("mixed synced", () => { describe("partition-based sync roundtrip", function () { this.longTimeout(); importAppBefore(buildAppConfig("with-pbs").anonAuth().partitionBasedSync()); @@ -241,4 +390,316 @@ describe("mixed", () => { importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); describeTypes(true); }); + + describe.skipIf(environment.skipFlexibleSync, "mixed collections", function () { + this.longTimeout(); + importAppBefore(buildAppConfig("with-flx").anonAuth().flexibleSync()); + + type MultiRealmContext = { + realm1: Realm; + realm2: Realm; + config1: Configuration; + config2: Configuration; + } & AppContext & + Mocha.Context; + + beforeEach(async function (this: MultiRealmContext) { + const config = { + schema: [MixedClass], + sync: { flexible: true }, + } satisfies OpenRealmConfiguration; + + this.realm1 = await logInAndGetRealm(this.app, config); + this.realm2 = await logInAndGetRealm(this.app, config); + + this.config1 = { ...config, sync: this.realm1.syncSession?.config }; + this.config2 = { ...config, sync: this.realm2.syncSession?.config }; + }); + + afterEach(async function (this: MultiRealmContext) { + closeAndDeleteRealms(this.config1, this.config2); + }); + + function closeAndDeleteRealms(...configs: Configuration[]) { + for (const config of configs) { + Realm.deleteFile(config); + } + Realm.clearTestState(); + } + + async function waitForSynchronization({ + uploadRealm, + downloadRealm, + }: { + uploadRealm: Realm; + downloadRealm: Realm; + }) { + await uploadRealm.syncSession?.uploadAllLocalChanges(); + await downloadRealm.syncSession?.downloadAllServerChanges(); + } + + async function logInAndGetRealm(app: Realm.App, config: OpenRealmConfiguration) { + const user = await app.logIn(Realm.Credentials.anonymous(false)); + const realm = (await openRealm(config, user)).realm; + + await realm.subscriptions.update((mutableSubs) => { + mutableSubs.add(realm.objects(MixedClass)); + }); + + await realm.subscriptions.waitForSynchronization(); + + return realm; + } + + function getWaiter(obj: MixedClass, propertyName: keyof MixedClass): Promise { + return new Promise((resolve) => { + obj.addListener((_, changes) => { + if (changes.changedProperties.includes(propertyName)) { + obj.removeAllListeners(); + resolve(); + } + }); + }); + } + + function waitForMixedClassObj(realm: Realm, obId: Realm.BSON.ObjectId): Promise { + return new Promise((resolve) => { + realm + .objects(MixedClass) + .filtered("_id = $0", obId) + .addListener(([obj]) => { + if (obj) { + resolve(obj); + } + }); + }); + } + + async function getObjects( + realm1: Realm, + realm2: Realm, + initialVal: Mixed, + ): Promise<{ obj1: MixedClass; obj2: MixedClass }> { + const obId = new Realm.BSON.ObjectId(); + const obj1 = realm1.write(() => { + return realm1.create(MixedClass, { + _id: obId, + value: initialVal, + }); + }); + + const obj2 = await waitForMixedClassObj(realm2, obId); + return { obj1, obj2 }; + } + + it("value change", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, null); + + const { values, expected } = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + for (let index = 0; index < values.length; index++) { + const val = values[index]; + const exp = expected[index]; + + realm1.write(() => { + obj1.value = val; + }); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, exp); + } + }); + + it("list adding", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, []); + + const { values, expected } = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + //We will keep this list updated with the values we expect to find + const expectedList = []; + + //Adding elements one by one and verifying the list is synchronized + for (let index = 0; index < values.length; index++) { + const val = values[index]; + const exp = expected[index]; + + realm1.write(() => { + (obj1.value as Realm.List).push(val); + }); + expectedList.push(exp); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList); + } + }); + + it("list removing", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const { values, expected } = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, values); + + //We will keep this list updated with the values we expect to find + const expectedList = [...expected]; + + //Removing elements one by one and verifying the list is synchronized + for (let index = 0; index < values.length; index++) { + realm1.write(() => { + (obj1.value as Realm.List).pop(); + }); + expectedList.pop(); + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList); + } + + expect((obj1.value as Realm.List).length).equals(0); + expect((obj2.value as Realm.List).length).equals(0); + }); + + it("list modification", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const { values, expected } = realm1.write(() => { + return getNestedMixedList(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, ["test"]); + + //We will keep this list updated with the values we expect to find + const expectedList: Mixed[] = ["test"]; + + //Changing the first element and verifying the list is synchronized + for (let index = 0; index < values.length; index++) { + const val = values[index]; + const exp = expected[index]; + + realm1.write(() => { + (obj1.value as Realm.List)[0] = val; + }); + expectedList[0] = exp; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedList); + } + + obj2.removeAllListeners(); + }); + + it("dictionary adding", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const { values, expected }: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, {}); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Adding elements one by one and verifying the dictionary is synchronized + for (const key in values) { + const val = values[key]; + const exp = expected[key]; + + realm1.write(() => { + (obj1.value as Realm.Dictionary)[key] = val; + }); + expectedDict[key] = exp; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict); + } + }); + + it("dictionary removing", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const { values, expected }: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, values); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict = { ...expected }; + + //Removing elements one by one and verifying the dictionary is synchronized + for (const key in values) { + realm1.write(() => { + (obj1.value as Realm.Dictionary).remove(key); + }); + delete expectedDict[key]; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict); + } + }); + + it("dictionary modification", async function (this: MultiRealmContext) { + const realm1 = this.realm1; + const realm2 = this.realm2; + + const { values, expected }: { [key: string]: any } = realm1.write(() => { + return getNestedMixedDict(realm1); + }); + + const keyString = "keyString"; + const { obj1, obj2 } = await getObjects(this.realm1, this.realm2, { [keyString]: 1 }); + + //We will keep this dictionary updated with the values we expect to find + const expectedDict: { [key: string]: any } = {}; + + //Modifying elements one by one and verifying the dictionary is synchronized + for (const key in values) { + const val = values[key]; + const exp = expected[key]; + + realm1.write(() => { + (obj1.value as Realm.Dictionary)[keyString] = val; + }); + expectedDict[keyString] = exp; + + const waitPromise = getWaiter(obj2, "value"); + await waitForSynchronization({ uploadRealm: realm1, downloadRealm: realm2 }); + await waitPromise; + + defaultTester(obj2.value, expectedDict); + } + }); + }); }); diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index 52e80159fa..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: @@ -297,6 +297,7 @@ classes: - get_key - get_any - set_any + - set_collection - get_linked_object - get_backlink_count - get_backlink_view @@ -331,6 +332,8 @@ classes: - index_of_obj - get_obj - get_any + - get_list + - get_dictionary - sort_by_names - snapshot - max @@ -383,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 @@ -414,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/js_spec.yml b/packages/realm/bindgen/js_spec.yml index 1fb7bbc882..b6e9f28542 100644 --- a/packages/realm/bindgen/js_spec.yml +++ b/packages/realm/bindgen/js_spec.yml @@ -25,4 +25,3 @@ classes: raw_dereference: sig: '() const -> Nullable' cppName: lock - diff --git a/packages/realm/bindgen/src/templates/base-wrapper.ts b/packages/realm/bindgen/src/templates/base-wrapper.ts index 607d0a4813..d50a8cdea1 100644 --- a/packages/realm/bindgen/src/templates/base-wrapper.ts +++ b/packages/realm/bindgen/src/templates/base-wrapper.ts @@ -84,6 +84,7 @@ export function generate({ spec: boundSpec }: TemplateContext, out: Outputter): "Decimal128", "EJSON_parse: EJSON.parse", "EJSON_stringify: EJSON.stringify", + "Symbol_for: Symbol.for", ]; for (const cls of spec.classes) { 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..9bb10be828 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 (https://github.com/realm/realm-core/issues/7422). + 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..0d1a00f7a0 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 (https://github.com/realm/realm-core/issues/7422). + 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; + private declare mixedToBinding: (value: unknown, options: { isQueryArg: boolean }) => binding.MixedArg; /** - * Get an element of the ordered collection by index. - * @param index - The index. - * @returns The element. + * Get an element of the collection. * @internal */ - public get(index: number): T { - return this.helpers.fromBinding(this.helpers.get(this.results, index)) as T; - } + public abstract get(index: number): T; /** - * Set an element of the ordered collection by index. - * @param index - The index. - * @param value - The value. + * Set an element in the collection. * @internal */ - public set(index: number, value: T): void; - public set() { - throw new Error(`Assigning into a ${this.constructor.name} is not supported`); - } + public abstract set(index: number, value: T): void; /** * The plain object representation for JSON serialization. @@ -269,10 +293,9 @@ export abstract class OrderedCollection { - 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) => - Array.isArray(arg) ? arg.map((sub) => this.mixedToBinding(sub)) : this.mixedToBinding(arg), - ); + const bindingArgs = args.map((arg) => this.queryArgToBinding(arg)); const newQuery = parent.query.table.query(queryString, bindingArgs, kpMapping); const results = binding.Helpers.resultsAppendQuery(parent, newQuery); - return new Results(realm, results, helpers); + + const itemType = toItemType(results.type); + const typeHelpers = this[TYPE_HELPERS]; + const accessor = createResultsAccessor({ realm, typeHelpers, itemType }); + return new Results(realm, results, accessor, typeHelpers); + } + + /** @internal */ + private queryArgToBinding(arg: unknown): binding.MixedArg | binding.MixedArg[] { + return Array.isArray(arg) + ? arg.map((innerArg) => this.mixedToBinding(innerArg, { isQueryArg: true })) + : this.mixedToBinding(arg, { isQueryArg: true }); } /** @@ -866,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") { @@ -882,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 */ @@ -927,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 2074910af6..0db8ac1aa9 100644 --- a/packages/realm/src/PropertyHelpers.ts +++ b/packages/realm/src/PropertyHelpers.ts @@ -19,7 +19,8 @@ import { ClassHelpers, Dictionary, - OrderedCollectionHelpers, + List, + ListAccessor, Realm, RealmSet, Results, @@ -28,7 +29,16 @@ import { TypeOptions, assert, binding, + createDictionaryAccessor, + createListAccessor, + createResultsAccessor, + createSetAccessor, getTypeHelpers, + insertIntoDictionaryOfMixed, + insertIntoListOfMixed, + isJsOrRealmDictionary, + isJsOrRealmList, + toItemType, } from "./internal"; type PropertyContext = binding.Property & { @@ -38,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; @@ -59,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; @@ -115,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) { @@ -152,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}`, @@ -166,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"); @@ -185,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}`, @@ -258,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}"]`); @@ -286,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}`, @@ -296,23 +275,66 @@ 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 } = 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) { + try { + 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); + } + } catch (err) { + assert.isValid(obj); + throw err; + } + }, + set(obj: binding.Obj, value: unknown) { + assert.inTransaction(realm); + + if (isJsOrRealmList(value)) { + obj.setCollection(columnKey, binding.CollectionType.List); + const internal = binding.List.make(realm.internal, obj, columnKey); + insertIntoListOfMixed(value, internal, toBinding); + } else if (isJsOrRealmDictionary(value)) { + obj.setCollection(columnKey, binding.CollectionType.Dictionary); + const internal = binding.Dictionary.make(realm.internal, obj, columnKey); + insertIntoDictionaryOfMixed(value, internal, toBinding); + } else { + defaultSet(options)(obj, value); } }, }; @@ -357,12 +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), }); } } diff --git a/packages/realm/src/Realm.ts b/packages/realm/src/Realm.ts index 5ed5aab8e7..8554852cec 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -48,10 +48,12 @@ import { SubscriptionSet, SyncSession, TypeAssertionError, + TypeHelpers, Unmanaged, UpdateMode, assert, binding, + createResultsAccessor, defaultLogger, defaultLoggerLevel, extendDebug, @@ -938,25 +940,27 @@ export class Realm { objects(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 be3da30a00..9de5f0d53e 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -20,21 +20,25 @@ import { BSON, ClassHelpers, Collection, - GeoBox, - GeoCircle, - GeoPolygon, + Dictionary, INTERNAL, List, ObjCreator, REALM, Realm, RealmObject, + RealmSet, TypeAssertionError, UpdateMode, assert, binding, boxToBindingGeospatial, circleToBindingGeospatial, + createDictionaryAccessor, + createListAccessor, + isGeoBox, + isGeoCircle, + isGeoPolygon, polygonToBindingGeospatial, safeGlobalThis, } from "./internal"; @@ -70,9 +74,15 @@ 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, options?: { createObj?: ObjCreator; updateMode?: UpdateMode }): binding.MixedArg; + toBinding( + value: T, + options?: { createObj?: ObjCreator; updateMode?: UpdateMode; isQueryArg?: boolean }, + ): binding.MixedArg; fromBinding(value: unknown): T; }; @@ -89,8 +99,21 @@ export type TypeOptions = { // TODO: Consider testing for expected object instance types and throw something similar to the legacy SDK: // "Only Realm instances are supported." (which should probably have been "RealmObject") // instead of relying on the binding to throw. -/** @internal */ -export function mixedToBinding(realm: binding.Realm, value: unknown): binding.MixedArg { +/** + * Convert an SDK value to a Binding value representation. + * @param realm The Realm used. + * @param value The value to convert. + * @param options Options needed. + * @param options.isQueryArg Whether the value to convert is a query argument used + * for `OrderedCollection.filtered()`. If so, this will be validated differently. + * @returns The `MixedArg` binding representation. + * @internal + */ +export function mixedToBinding( + realm: binding.Realm, + value: unknown, + { isQueryArg } = { isQueryArg: false }, +): binding.MixedArg { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { // Fast track pass through for the most commonly used types return value; @@ -99,46 +122,73 @@ export function mixedToBinding(realm: binding.Realm, value: unknown): binding.Mi } else if (value instanceof Date) { return binding.Timestamp.fromDate(value); } else if (value instanceof RealmObject) { + if (value.objectSchema().embedded) { + throw new Error(`Using an embedded object (${value.constructor.name}) as a Mixed value is not supported.`); + } const otherRealm = value[REALM].internal; assert.isSameRealm(realm, otherRealm, "Realm object is from another Realm"); return value[INTERNAL]; - } else if (value instanceof Collection) { - throw new Error(`Using a ${value.constructor.name} as Mixed value, is not yet supported`); - } else if (Array.isArray(value)) { - throw new TypeError("A mixed property cannot contain an array of values."); + } else if (value instanceof RealmSet || value instanceof Set) { + throw new Error(`Using a ${value.constructor.name} as a Mixed value is not supported.`); } else { - if (typeof value === "object" && value !== null) { - if (isGeoCircle(value)) { - return circleToBindingGeospatial(value); - } else if (isGeoBox(value)) { - return boxToBindingGeospatial(value); - } else if (isGeoPolygon(value)) { - return polygonToBindingGeospatial(value); + if (isQueryArg) { + if (value instanceof Collection || Array.isArray(value)) { + throw new Error(`Using a ${value.constructor.name} as a query argument is not supported.`); + } + // Geospatial types can currently only be used when querying and + // are not yet supported as standalone data types in the schema. + if (typeof value === "object") { + if (isGeoCircle(value)) { + return circleToBindingGeospatial(value); + } else if (isGeoBox(value)) { + return boxToBindingGeospatial(value); + } else if (isGeoPolygon(value)) { + return polygonToBindingGeospatial(value); + } } } + // Convert typed arrays to an `ArrayBuffer` 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; } } -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 mixedFromBinding(options: TypeOptions, value: binding.MixedArg): unknown { + const { realm, getClassHelpers } = options; + if (binding.Int64.isInt(value)) { + return binding.Int64.intToNum(value); + } else if (value instanceof binding.Timestamp) { + return value.toDate(); + } else if (value instanceof binding.Float) { + return value.value; + } else if (value instanceof binding.ObjLink) { + const table = binding.Helpers.getTable(realm.internal, value.tableKey); + const linkedObj = table.getObject(value.objKey); + const { wrapObject } = getClassHelpers(value.tableKey); + return wrapObject(linkedObj); + } else if (value instanceof binding.List) { + const 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 mixedType = binding.PropertyType.Mixed; + const typeHelpers = getTypeHelpers(mixedType, options); + return new Dictionary( + realm, + value, + createDictionaryAccessor({ realm, typeHelpers, itemType: mixedType }), + typeHelpers, + ); + } else { + return value; + } } function defaultToBinding(value: unknown): binding.MixedArg { @@ -294,25 +344,10 @@ const TYPES_MAPPING: Record Type }, }; }, - [binding.PropertyType.Mixed]({ realm, getClassHelpers }) { + [binding.PropertyType.Mixed](options) { return { - toBinding: mixedToBinding.bind(null, realm.internal), - fromBinding(value) { - if (binding.Int64.isInt(value)) { - return binding.Int64.intToNum(value); - } else if (value instanceof binding.Timestamp) { - return value.toDate(); - } else if (value instanceof binding.Float) { - return value.value; - } else if (value instanceof binding.ObjLink) { - const table = binding.Helpers.getTable(realm.internal, value.tableKey); - const linkedObj = table.getObject(value.objKey); - const { wrapObject } = getClassHelpers(value.tableKey); - return wrapObject(linkedObj); - } else { - return value; - } - }, + toBinding: mixedToBinding.bind(null, options.realm.internal), + fromBinding: mixedFromBinding.bind(null, options), }; }, [binding.PropertyType.ObjectId]({ optional }) { @@ -345,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"); @@ -390,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/collection-helpers.test.ts b/packages/realm/src/tests/collection-helpers.test.ts new file mode 100644 index 0000000000..b80cd2db43 --- /dev/null +++ b/packages/realm/src/tests/collection-helpers.test.ts @@ -0,0 +1,73 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { expect } from "chai"; + +import { isPOJO } from "../Dictionary"; + +describe("Collection helpers", () => { + describe("isPOJO()", () => { + it("returns true for object literal", () => { + const object = {}; + expect(object.constructor).to.equal(Object); + expect(isPOJO(object)).to.be.true; + }); + + it("returns true for Object constructor", () => { + const object = new Object(); + expect(object.constructor).to.equal(Object); + expect(isPOJO(object)).to.be.true; + }); + + it("returns true for Object without prototype", () => { + let object = Object.assign(Object.create(null), {}); + expect(object.constructor).to.be.undefined; + expect(Object.getPrototypeOf(object)).to.be.null; + expect(isPOJO(object)).to.be.true; + + object = Object.create(null); + expect(object.constructor).to.be.undefined; + expect(Object.getPrototypeOf(object)).to.be.null; + expect(isPOJO(object)).to.be.true; + }); + + it("returns false for user-defined class", () => { + class CustomClass {} + const object = new CustomClass(); + expect(object.constructor).to.equal(CustomClass); + expect(isPOJO(object)).to.be.false; + }); + + // 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; + expect(isPOJO(new Array(1))).to.be.false; + }); + + it("returns false for null", () => { + expect(isPOJO(null)).to.be.false; + }); + }); +});