Skip to content

Commit

Permalink
Implement support for flat collections in Mixed (#6364)
Browse files Browse the repository at this point in the history
* Differentiate use of 'mixedToBinding()' for query arg validation.

* Refactor 'mixedFromBinding()' to own function and account for List and Dictionary.

* Implement setting a flat list and dictionary in Mixed.

* Implement accessing a flat list and dictionary in Mixed.

* Add tests for storing and accessing flat lists and dictionaries in Mixed.

* Refactor helper in test to not rely on collection position.

* Add tests for Set in Mixed throwing.

* Add tests for updating lists and dictionaries.

* Add tests for removing items in lists and dictionaries.

* Add tests for filtering lists and dictionaries by path.

* Throw if adding a set via property accessors.

* Group tests into separate sub-suites.

* Guard for embedded objects being set as Mixed value.

* Add tests for embedded objects in Mixed throwing.

* Add more filtering tests.

* Add 'at_keys' query tests to uncomment after Core bug fix.

* Add tests for inserting into lists and dictionaries.

* Add tests for notifications on lists.

* Add tests for notifications on dictionaries.

* Add tests for notifications on object when changed prop is list in mixed.

* Add tests for invalidating old list and dictionary.

* Minor updates to names.

* Add tests for notifications on object when changed prop is dictionary in mixed.

* Add tests for creating dictionary via object without prototype.

* Add tests filtering by query path using IN.

* Access array of changes only 1 time in notifications tests.

* Remove unnecessary type assertion.

* Update schema object names in tests.

* Add link to Core bug.

* Add tests for default list and dictionary in schema.

* Add tests for setting lists and dictionaries outside transaction.

* Add tests for spreading Realm and non-Realm objects as Dictionary.

* Add unit tests for 'isPOJO()'.

* Point to updated Core commit and enable related tests.

* Wrap chai's 'instanceOf()' in custom helper to assert type.

* Update helper function name to be consistent with other helpers.

* Add internal docs for 'isQueryArg'.

* Rename unit test file.

* Refactor notification tests into 'observable.ts'.

* Refactor notification tests to use test context.

* Use named import of 'ObjectSchema'.

* Group CRUD tests into subsuites.
  • Loading branch information
elle-j committed Feb 1, 2024
1 parent 276dc67 commit 90cac30
Show file tree
Hide file tree
Showing 8 changed files with 1,250 additions and 69 deletions.
738 changes: 709 additions & 29 deletions integration-tests/tests/src/tests/mixed.ts

Large diffs are not rendered by default.

318 changes: 313 additions & 5 deletions integration-tests/tests/src/tests/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
);
Expand All @@ -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,
);
Expand Down Expand Up @@ -1373,4 +1376,309 @@ describe("Observable", () => {
});
});
});

describe("Collections in Mixed", () => {
class ObjectWithMixed extends Realm.Object<ObjectWithMixed> {
mixedValue!: Realm.Types.Mixed;

static schema: ObjectSchema = {
name: "ObjectWithMixed",
properties: {
mixedValue: "mixed",
},
};
}

type CollectionsInMixedContext = {
objectWithList: Realm.Object<ObjectWithMixed> & ObjectWithMixed;
objectWithDictionary: Realm.Object<ObjectWithMixed> & ObjectWithMixed;
list: Realm.List<any>;
dictionary: Realm.Dictionary<any>;
} & RealmContext;

openRealmBeforeEach({ schema: [ObjectWithMixed] });

beforeEach(function (this: CollectionsInMixedContext) {
this.objectWithList = this.realm.write(() => {
return this.realm.create(ObjectWithMixed, { mixedValue: [] });
});
this.list = this.objectWithList.mixedValue as Realm.List<any>;
expect(this.list).instanceOf(Realm.List);

this.objectWithDictionary = this.realm.write(() => {
return this.realm.create(ObjectWithMixed, { mixedValue: {} });
});
this.dictionary = this.objectWithDictionary.mixedValue as Realm.Dictionary<any>;
expect(this.dictionary).instanceOf(Realm.Dictionary);
});

describe("Collection notifications", () => {
it("fires when inserting to top-level list", async function (this: CollectionsInMixedContext) {
await expectCollectionNotifications(this.list, undefined, [
EMPTY_COLLECTION_CHANGESET,
() => {
this.realm.write(() => {
this.list.push("Amy");
this.list.push("Mary");
this.list.push("John");
});
},
{
deletions: [],
insertions: [0, 1, 2],
newModifications: [],
oldModifications: [],
},
]);
});

it("fires when inserting to top-level dictionary", async function (this: CollectionsInMixedContext) {
await expectDictionaryNotifications(this.dictionary, undefined, [
EMPTY_DICTIONARY_CHANGESET,
() => {
this.realm.write(() => {
this.dictionary.amy = "Amy";
this.dictionary.mary = "Mary";
this.dictionary.john = "John";
});
},
{
deletions: [],
insertions: ["amy", "mary", "john"],
modifications: [],
},
]);
});

it("fires when updating top-level list", async function (this: CollectionsInMixedContext) {
await expectCollectionNotifications(this.list, undefined, [
EMPTY_COLLECTION_CHANGESET,
() => {
this.realm.write(() => {
this.list.push("Amy");
this.list.push("Mary");
this.list.push("John");
});
},
{
deletions: [],
insertions: [0, 1, 2],
newModifications: [],
oldModifications: [],
},
() => {
this.realm.write(() => {
this.list[0] = "Updated Amy";
this.list[2] = "Updated John";
});
},
{
deletions: [],
insertions: [],
newModifications: [0, 2],
oldModifications: [0, 2],
},
]);
});

it("fires when updating top-level dictionary", async function (this: CollectionsInMixedContext) {
await expectDictionaryNotifications(this.dictionary, undefined, [
EMPTY_DICTIONARY_CHANGESET,
() => {
this.realm.write(() => {
this.dictionary.amy = "Amy";
this.dictionary.mary = "Mary";
this.dictionary.john = "John";
});
},
{
deletions: [],
insertions: ["amy", "mary", "john"],
modifications: [],
},
() => {
this.realm.write(() => {
this.dictionary.amy = "Updated Amy";
this.dictionary.john = "Updated John";
});
},
{
deletions: [],
insertions: [],
modifications: ["amy", "john"],
},
]);
});

it("fires when deleting from top-level list", async function (this: CollectionsInMixedContext) {
await expectCollectionNotifications(this.list, undefined, [
EMPTY_COLLECTION_CHANGESET,
() => {
this.realm.write(() => {
this.list.push("Amy");
this.list.push("Mary");
this.list.push("John");
});
},
{
deletions: [],
insertions: [0, 1, 2],
newModifications: [],
oldModifications: [],
},
() => {
this.realm.write(() => {
this.list.remove(2);
});
},
{
deletions: [2],
insertions: [],
newModifications: [],
oldModifications: [],
},
]);
});

it("fires when deleting from top-level dictionary", async function (this: CollectionsInMixedContext) {
await expectDictionaryNotifications(this.dictionary, undefined, [
EMPTY_DICTIONARY_CHANGESET,
() => {
this.realm.write(() => {
this.dictionary.amy = "Amy";
this.dictionary.mary = "Mary";
this.dictionary.john = "John";
});
},
{
deletions: [],
insertions: ["amy", "mary", "john"],
modifications: [],
},
() => {
this.realm.write(() => {
this.dictionary.remove("mary");
});
},
{
deletions: ["mary"],
insertions: [],
modifications: [],
},
]);
});

it("does not fire when updating object in top-level list", async function (this: CollectionsInMixedContext) {
const realmObjectInList = this.realm.write(() => {
return this.realm.create(ObjectWithMixed, { mixedValue: "original" });
});

await expectCollectionNotifications(this.list, undefined, [
EMPTY_COLLECTION_CHANGESET,
() => {
this.realm.write(() => {
this.list.push(realmObjectInList);
});
expect(this.list.length).equals(1);
expect(realmObjectInList.mixedValue).equals("original");
},
{
deletions: [],
insertions: [0],
newModifications: [],
oldModifications: [],
},
() => {
this.realm.write(() => {
realmObjectInList.mixedValue = "updated";
});
expect(realmObjectInList.mixedValue).equals("updated");
},
]);
});

it("does not fire when updating object in top-level dictionary", async function (this: CollectionsInMixedContext) {
const realmObjectInDictionary = this.realm.write(() => {
return this.realm.create(ObjectWithMixed, { mixedValue: "original" });
});

await expectDictionaryNotifications(this.dictionary, undefined, [
EMPTY_DICTIONARY_CHANGESET,
() => {
this.realm.write(() => {
this.dictionary.realmObject = realmObjectInDictionary;
});
expect(realmObjectInDictionary.mixedValue).equals("original");
},
{
deletions: [],
insertions: ["realmObject"],
modifications: [],
},
() => {
this.realm.write(() => {
realmObjectInDictionary.mixedValue = "updated";
});
expect(realmObjectInDictionary.mixedValue).equals("updated");
},
]);
});
});

describe("Object notifications", () => {
it("fires when inserting, updating, and deleting in top-level list", async function (this: CollectionsInMixedContext) {
await expectObjectNotifications(this.objectWithList, undefined, [
EMPTY_OBJECT_CHANGESET,
// Insert list item.
() => {
this.realm.write(() => {
this.list.push("Amy");
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
// Update list item.
() => {
this.realm.write(() => {
this.list[0] = "Updated Amy";
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
// Delete list item.
() => {
this.realm.write(() => {
this.list.remove(0);
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
]);
});

it("fires when inserting, updating, and deleting in top-level dictionary", async function (this: CollectionsInMixedContext) {
await expectObjectNotifications(this.objectWithDictionary, undefined, [
EMPTY_OBJECT_CHANGESET,
// Insert dictionary item.
() => {
this.realm.write(() => {
this.dictionary.amy = "Amy";
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
// Update dictionary item.
() => {
this.realm.write(() => {
this.dictionary.amy = "Updated Amy";
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
// Delete dictionary item.
() => {
this.realm.write(() => {
this.dictionary.remove("amy");
});
},
{ deleted: false, changedProperties: ["mixedValue"] },
]);
});
});
});
});
2 changes: 2 additions & 0 deletions packages/realm/bindgen/js_opt_in_spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ classes:
- get_results_description
- feed_buffer
- make_ssl_verify_callback
- get_mixed_type

LogCategoryRef:
methods:
Expand Down Expand Up @@ -279,6 +280,7 @@ classes:
- get_key
- get_any
- set_any
- set_collection
- get_linked_object
- get_backlink_count
- get_backlink_view
Expand Down
1 change: 0 additions & 1 deletion packages/realm/bindgen/js_spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,3 @@ classes:
raw_dereference:
sig: '() const -> Nullable<SharedSyncSession>'
cppName: lock

14 changes: 10 additions & 4 deletions packages/realm/src/OrderedCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown,

/** @internal */
protected declare classHelpers: ClassHelpers | null;
private declare mixedToBinding: (value: unknown) => binding.MixedArg;
/** @internal */
private declare mixedToBinding: (value: unknown, options: { isQueryArg: boolean }) => binding.MixedArg;

/**
* Get an element of the ordered collection by index.
Expand Down Expand Up @@ -777,14 +778,19 @@ export abstract class OrderedCollection<T = unknown, EntryType extends [unknown,
filtered(queryString: string, ...args: unknown[]): Results<T> {
const { results: parent, realm, helpers } = this;
const kpMapping = binding.Helpers.getKeypathMapping(realm.internal);
const bindingArgs = args.map((arg) =>
Array.isArray(arg) ? arg.map((sub) => this.mixedToBinding(sub)) : this.mixedToBinding(arg),
);
const bindingArgs = args.map((arg) => this.queryArgToBinding(arg));
const newQuery = parent.query.table.query(queryString, bindingArgs, kpMapping);
const results = binding.Helpers.resultsAppendQuery(parent, newQuery);
return new Results(realm, results, helpers);
}

/** @internal */
private queryArgToBinding(arg: unknown): binding.MixedArg | binding.MixedArg[] {
return Array.isArray(arg)
? arg.map((innerArg) => this.mixedToBinding(innerArg, { isQueryArg: true }))
: this.mixedToBinding(arg, { isQueryArg: true });
}

/**
* Returns new _Results_ that represent a sorted view of this collection.
*
Expand Down

0 comments on commit 90cac30

Please sign in to comment.