diff --git a/CHANGELOG.md b/CHANGELOG.md index bf57e39c3d..a13d3af99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,11 @@ * Reduce the size of the local transaction log produced by creating objects, improving the performance of insertion-heavy transactions. ([realm/realm-core#7734](https://github.com/realm/realm-core/pull/7734)) ### Fixed -* A non-streaming progress notifier would not immediately call its callback after registration. Instead you would have to wait for a download message to be received to get your first update - if you were already caught up when you registered the notifier you could end up waiting a long time for the server to deliver a download that would call/expire your notifier. ([#7627](https://github.com/realm/realm-core/issues/7627), since v12.8.0) * After compacting, a file upgrade would be triggered. This could cause loss of data for synced Realms. ([realm/realm-core#7747](https://github.com/realm/realm-core/issues/7747), since 12.7.0-rc.0) * The function `immediatelyRunFileActions` was not added to the bindgen's opt-list. This could lead to the error `TypeError: app.internal.immediatelyRunFileActions is not a function`. ([#6708](https://github.com/realm/realm-js/issues/6708), since v12.8.0) * Encrypted files on Windows had a maximum size of 2 GB even on x64 due to internal usage of `off_t`, which is a 32-bit type on 64-bit Windows. ([realm/realm-core#7698](https://github.com/realm/realm-core/pull/7698), since the introduction of encryption support on Windows - likely in v1.11.0) * Tokenizing strings for full-text search could lead to undefined behavior. ([realm/realm-core#7698](https://github.com/realm/realm-core/pull/7698), since v11.3.0-rc.0) - ### Compatibility * React Native >= v0.71.4 * Realm Studio v15.0.0. @@ -23,6 +21,48 @@ ### Internal * Upgraded Realm Core from v14.7.0 to v14.10.0. ([#6701](https://github.com/realm/realm-js/issues/6701)) +## 12.10.0-rc.0 (2024-05-31) + +### Enhancements +* A `counter` presentation data type has been introduced. The `int` data type can now be used as a logical counter for performing numeric updates that need to be synchronized as sequentially consistent events rather than individual reassignments of the number. ([#6694](https://github.com/realm/realm-js/pull/6694)) + * See the [API docs](https://www.mongodb.com/docs/realm-sdks/js/latest/classes/Realm.Types.Counter.html) for more information about the usage, or get a high-level introduction about counters in the [documentation](https://www.mongodb.com/docs/atlas/app-services/sync/details/conflict-resolution/#counters). +```typescript +class MyObject extends Realm.Object { + _id!: BSON.ObjectId; + counter!: Realm.Types.Counter; + + static schema: ObjectSchema = { + name: "MyObject", + primaryKey: "_id", + properties: { + _id: { type: "objectId", default: () => new BSON.ObjectId() }, + counter: "counter", + // or: counter: { type: "int", presentation: "counter" }, + }, + }; +} + +const realm = await Realm.open({ schema: [MyObject] }); +const object = realm.write(() => { + return realm.create(MyObject, { counter: 0 }); +}); + +realm.write(() => { + object.counter.increment(); + object.counter.value; // 1 + object.counter.decrement(2); + object.counter.value; // -1 +}); +``` + +### Fixed +* A non-streaming progress notifier would not immediately call its callback after registration. Instead you would have to wait for a download message to be received to get your first update - if you were already caught up when you registered the notifier you could end up waiting a long time for the server to deliver a download that would call/expire your notifier. ([#7627](https://github.com/realm/realm-core/issues/7627), since v12.8.0) + +### Compatibility +* React Native >= v0.71.4 +* Realm Studio v15.0.0. +* File format: generates Realms with format v24 (reads and upgrades file format v10). + ## 12.9.0 (2024-05-23) ### Enhancements diff --git a/integration-tests/tests/src/tests.ts b/integration-tests/tests/src/tests.ts index cbddde1842..6bd005fd52 100644 --- a/integration-tests/tests/src/tests.ts +++ b/integration-tests/tests/src/tests.ts @@ -50,6 +50,7 @@ import "./tests/alias"; import "./tests/array-buffer"; import "./tests/bson"; import "./tests/class-models"; +import "./tests/counter"; import "./tests/dictionary"; import "./tests/dynamic-schema-updates"; import "./tests/enums"; diff --git a/integration-tests/tests/src/tests/counter.ts b/integration-tests/tests/src/tests/counter.ts new file mode 100644 index 0000000000..e7fb88d35f --- /dev/null +++ b/integration-tests/tests/src/tests/counter.ts @@ -0,0 +1,919 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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 Realm, { BSON, Counter, ObjectSchema, UpdateMode } from "realm"; + +import { openRealmBeforeEach } from "../hooks"; +import { expectCounter, expectRealmDictionary, expectRealmList, expectRealmSet } from "../utils/expects"; + +interface IWithCounter { + _id: BSON.ObjectId; + counter: Counter; +} + +const WithCounterSchema: ObjectSchema = { + name: "WithCounter", + primaryKey: "_id", + properties: { + _id: { type: "objectId", default: () => new BSON.ObjectId() }, + counter: "counter", + }, +}; + +interface IWithNullableCounter { + _id: BSON.ObjectId; + nullableCounter?: Counter | null; +} + +const WithNullableCounterSchema: ObjectSchema = { + name: "WithNullableCounter", + primaryKey: "_id", + properties: { + _id: { type: "objectId", default: () => new BSON.ObjectId() }, + nullableCounter: "counter?", + }, +}; + +interface IWithDefaultCounter { + counterWithDefault: Counter; +} + +const WithDefaultCounterSchema: ObjectSchema = { + name: "WithDefaultCounter", + properties: { + counterWithDefault: { type: "int", presentation: "counter", default: 10 }, + }, +}; + +interface IWithRegularInt { + int: number; +} + +const WithRegularIntSchema: ObjectSchema = { + name: "WithRegularInt", + properties: { + int: "int", + }, +}; + +interface IWithMixed { + mixed: Realm.Types.Mixed; + list: Realm.List; + dictionary: Realm.Dictionary; + set: Realm.Set; +} + +const WithMixedSchema: ObjectSchema = { + name: "WithMixed", + properties: { + mixed: "mixed", + list: "mixed[]", + dictionary: "mixed{}", + set: "mixed<>", + }, +}; + +function expectKeys(dictionary: Realm.Dictionary, keys: string[]) { + expect(Object.keys(dictionary)).members(keys); +} + +describe("Counter", () => { + openRealmBeforeEach({ + schema: [ + WithCounterSchema, + WithNullableCounterSchema, + WithDefaultCounterSchema, + WithRegularIntSchema, + WithMixedSchema, + ], + }); + + const initialValues = [-100, 0, 1.0, 1000] as const; + + describe("create and access", () => { + describe("via 'realm.create()'", () => { + it("can create and access (input: number)", function (this: RealmContext) { + for (let i = 0; i < initialValues.length; i++) { + const input = initialValues[i]; + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: input, + }); + }); + + const expectedNumObjects = i + 1; + expect(this.realm.objects(WithCounterSchema.name).length).equals(expectedNumObjects); + expectCounter(counter); + expect(counter.value).equals(input); + } + }); + + it("can create and access (input: Counter)", function (this: RealmContext) { + const initialNumValues = initialValues; + // First create Realm objects with counters. + const initialCounterValues = this.realm.write(() => { + return initialNumValues.map((input) => { + const { counter } = this.realm.create(WithCounterSchema.name, { + counter: input, + }); + expectCounter(counter); + expect(counter.value).equals(input); + return counter; + }); + }); + + // Use the managed Counters as input, each in a separate transaction. + for (let i = 0; i < initialCounterValues.length; i++) { + const input = initialCounterValues[i]; + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: input, + }); + }); + + const expectedNumObjects = initialNumValues.length + i + 1; + expect(this.realm.objects(WithCounterSchema.name).length).equals(expectedNumObjects); + expectCounter(counter); + expect(counter.value).equals(input.value); + } + }); + + it("can create and access (input: default value)", function (this: RealmContext) { + const { counterWithDefault } = this.realm.write(() => { + // Pass an empty object in order to use the default value from the schema. + return this.realm.create(WithDefaultCounterSchema.name, {}); + }); + + expect(this.realm.objects(WithDefaultCounterSchema.name).length).equals(1); + expectCounter(counterWithDefault); + expect(counterWithDefault.value).equals(10); + }); + + it("can create nullable counter with int or null", function (this: RealmContext) { + const { counter1, counter2 } = this.realm.write(() => { + const counter1 = this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: 10, + }).nullableCounter; + + const counter2 = this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: null, + }).nullableCounter; + + return { counter1, counter2 }; + }); + + expect(this.realm.objects(WithNullableCounterSchema.name).length).equals(2); + expectCounter(counter1); + expect(counter1.value).equals(10); + expect(counter2).to.be.null; + }); + + it("returns different reference for each access", function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 0, + }); + }); + + expectCounter(object.counter); + // @ts-expect-error Testing different types. + expect(object.counter === 0).to.be.false; + expect(object.counter === object.counter).to.be.false; + expect(Object.is(object.counter, object.counter)).to.be.false; + }); + }); + }); + + describe("update", () => { + describe("Counter.value", () => { + it("increments", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 0, + }); + }); + expectCounter(counter); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.increment(0.0); + }); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.increment(); + }); + expect(counter.value).equals(1); + + this.realm.write(() => { + counter.increment(Number(19)); + }); + expect(counter.value).equals(20); + + this.realm.write(() => { + counter.increment(-20); + }); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.increment(); + counter.increment(); + counter.increment(); + }); + expect(counter.value).equals(3); + }); + + it("decrements", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 0, + }); + }); + expectCounter(counter); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.decrement(0.0); + }); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.decrement(); + }); + expect(counter.value).equals(-1); + + this.realm.write(() => { + counter.decrement(Number(19)); + }); + expect(counter.value).equals(-20); + + this.realm.write(() => { + counter.decrement(-20.0); + }); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.decrement(); + counter.decrement(); + counter.decrement(); + }); + expect(counter.value).equals(-3); + }); + + it("sets", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 0, + }); + }); + expectCounter(counter); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.set(0.0); + }); + expect(counter.value).equals(0); + + this.realm.write(() => { + counter.set(1); + }); + expect(counter.value).equals(1); + + this.realm.write(() => { + counter.set(20.0); + }); + expect(counter.value).equals(20); + + this.realm.write(() => { + counter.set(100_000); + }); + expect(counter.value).equals(100_000); + + this.realm.write(() => { + counter.set(1); + counter.set(2); + counter.set(3); + }); + expect(counter.value).equals(3); + }); + }); + + describe("Realm object counter property", () => { + it("updates nullable counter from int -> null -> int via setter", function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: 0, + }); + }); + expectCounter(object.nullableCounter); + + this.realm.write(() => { + object.nullableCounter = null; + }); + expect(object.nullableCounter).to.be.null; + + this.realm.write(() => { + // @ts-expect-error Cannot currently express in TS that a Counter can be set to a number (while 'get' returns Counter). + object.nullableCounter = 1; + }); + expectCounter(object.nullableCounter); + expect(object.nullableCounter.value).equals(1); + + this.realm.write(() => { + object.nullableCounter = undefined; + }); + expect(object.nullableCounter).to.be.null; + + this.realm.write(() => { + // @ts-expect-error Cannot currently express in TS that a Counter can be set to a number (while 'get' returns Counter). + object.nullableCounter = -100_000; + }); + expectCounter(object.nullableCounter); + expect(object.nullableCounter.value).equals(-100_000); + }); + + for (const updateMode of [UpdateMode.Modified, UpdateMode.All]) { + it(`updates nullable counter from int -> null -> int via UpdateMode: ${updateMode}`, function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: 0, + }); + }); + expectCounter(object.nullableCounter); + + const _id = object._id; + + this.realm.write(() => { + this.realm.create( + WithNullableCounterSchema.name, + { + _id, + nullableCounter: null, + }, + updateMode, + ); + }); + expect(object.nullableCounter).to.be.null; + + this.realm.write(() => { + this.realm.create( + WithNullableCounterSchema.name, + { + _id, + nullableCounter: 1, + }, + updateMode, + ); + }); + expectCounter(object.nullableCounter); + expect(object.nullableCounter.value).equals(1); + + this.realm.write(() => { + this.realm.create( + WithNullableCounterSchema.name, + { + _id, + nullableCounter: null, + }, + updateMode, + ); + }); + expect(object.nullableCounter).to.be.null; + + this.realm.write(() => { + this.realm.create( + WithNullableCounterSchema.name, + { + _id, + nullableCounter: -100_000, + }, + updateMode, + ); + }); + expectCounter(object.nullableCounter); + expect(object.nullableCounter.value).equals(-100_000); + }); + } + }); + }); + + describe("filtering", () => { + it("filters objects with counters", function (this: RealmContext) { + this.realm.write(() => { + this.realm.create(WithCounterSchema.name, { counter: -100_000 }); + + this.realm.create(WithCounterSchema.name, { counter: 10 }); + this.realm.create(WithCounterSchema.name, { counter: 10 }); + this.realm.create(WithCounterSchema.name, { counter: 10 }); + + this.realm.create(WithCounterSchema.name, { counter: 500 }); + }); + const objects = this.realm.objects(WithCounterSchema.name); + expect(objects.length).equals(5); + + let filtered = objects.filtered("counter > 10"); + expect(filtered.length).equals(1); + + filtered = objects.filtered("counter < $0", 501); + expect(filtered.length).equals(5); + + filtered = objects.filtered("counter = 10"); + expect(filtered.length).equals(3); + + this.realm.write(() => { + for (const object of objects) { + object.counter.set(0); + } + }); + expect(filtered.length).equals(0); + }); + }); + + describe("Realm.schema", () => { + it("includes `presentation: 'counter'` in the canonical property schema", function (this: RealmContext) { + const objectSchema = this.realm.schema.find((objectSchema) => objectSchema.name === WithCounterSchema.name); + expect(objectSchema).to.be.an("object"); + + const counterPropertySchema = objectSchema?.properties.counter; + expect(counterPropertySchema).deep.equals({ + name: "counter", + type: "int", + presentation: "counter", + optional: false, + indexed: false, + mapTo: "counter", + default: undefined, + }); + }); + }); + + describe("invalid operations", () => { + const operations = [ + { opName: "increment", argName: "by" }, + { opName: "decrement", argName: "by" }, + { opName: "set", argName: "value" }, + ] as const; + + for (const { opName, argName } of operations) { + it(`throws when calling ${opName} with non-integer`, function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + expect(counter.value).equals(10); + + const operation = counter[opName].bind(counter); + + expect(() => { + this.realm.write(() => { + operation(1.1); + }); + }).to.throw(`Expected '${argName}' to be an integer, got a decimal number`); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + operation(NaN); + }); + }).to.throw(`Expected '${argName}' to be an integer, got NaN`); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Testing incorrect type. + operation(new Number(1)); + }); + }).to.throw(`Expected '${argName}' to be an integer, got an instance of Number`); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Testing incorrect type. + operation("1"); + }); + }).to.throw(`Expected '${argName}' to be an integer, got a string`); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Testing incorrect type. + operation(BigInt(1)); + }); + }).to.throw(`Expected '${argName}' to be an integer, got a bigint`); + expect(counter.value).equals(10); + }); + } + + it("throws when setting 'Counter.value' directly", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Assigning to read-only property. + counter.value = 20; + }); + }).to.throw("To update the value, use the methods on the Counter"); + expect(counter.value).equals(10); + }); + + it("throws when updating outside write transaction", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + counter.increment(); + }).to.throw("Cannot modify managed objects outside of a write transaction."); + expect(counter.value).equals(10); + + expect(() => { + counter.decrement(); + }).to.throw("Cannot modify managed objects outside of a write transaction."); + expect(counter.value).equals(10); + + expect(() => { + counter.set(1); + }).to.throw("Cannot modify managed objects outside of a write transaction."); + expect(counter.value).equals(10); + }); + + it("throws when setting a non-nullable counter via setter", function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + const counter = object.counter; + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Cannot currently express in TS that a Counter can be set to a number (while 'get' returns Counter). + object.counter = 0; + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Testing incorrect type. + object.counter = null; + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.counter.value).equals(10); + }); + + for (const updateMode of [UpdateMode.Modified, UpdateMode.All]) { + it(`throws when setting a non-nullable counter via UpdateMode: ${updateMode}`, function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + const counter = object.counter; + expectCounter(counter); + expect(counter.value).equals(10); + + const _id = object._id; + + expect(() => { + this.realm.write(() => { + this.realm.create( + WithCounterSchema.name, + { + _id, + counter: 0, + }, + updateMode, + ); + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + this.realm.create( + WithCounterSchema.name, + { + _id, + // @ts-expect-error Testing incorrect type. + counter: null, + }, + updateMode, + ); + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.counter.value).equals(10); + }); + } + + it("throws when setting a nullable counter from number -> number via setter", function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: 10, + }); + }); + const counter = object.nullableCounter; + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Cannot currently express in TS that a Counter can be set to a number (while 'get' returns Counter). + object.nullableCounter = 0; + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.nullableCounter?.value).equals(10); + }); + + for (const updateMode of [UpdateMode.Modified, UpdateMode.All]) { + it(`throws when setting a nullable counter from number -> number via UpdateMode: ${updateMode}`, function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithNullableCounterSchema.name, { + nullableCounter: 10, + }); + }); + const counter = object.nullableCounter; + expectCounter(counter); + expect(counter.value).equals(10); + + const _id = object._id; + + expect(() => { + this.realm.write(() => { + this.realm.create( + WithNullableCounterSchema.name, + { + _id, + nullableCounter: 0, + }, + updateMode, + ); + }); + }).to.throw( + "You can only reset a Counter instance when initializing a previously null Counter or resetting a nullable Counter to null. To update the value of the Counter, use its instance methods", + ); + expect(object.nullableCounter?.value).equals(10); + }); + } + + it("throws when setting an int property to a counter", function (this: RealmContext) { + const { objectWithInt, counter } = this.realm.write(() => { + const objectWithInt = this.realm.create(WithRegularIntSchema.name, { + int: 10, + }); + // Create and object with a counter that will be used for setting an 'int' property. + const { counter } = this.realm.create(WithCounterSchema.name, { + counter: 20, + }); + return { objectWithInt, counter }; + }); + expectCounter(counter); + expect(counter.value).equals(20); + expect(objectWithInt.int).equals(10); + + expect(() => { + this.realm.write(() => { + // @ts-expect-error Testing incorrect type. + objectWithInt.int = counter; + }); + }).to.throw("Counters can only be used when 'counter' is declared in the property schema"); + expect(objectWithInt.int).equals(10); + }); + + it("throws when getting the count on an invalidated obj", function (this: RealmContext) { + const object = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + const counter = object.counter; + expectCounter(counter); + expect(counter.value).equals(10); + expect(this.realm.objects(WithCounterSchema.name).length).equals(1); + + this.realm.write(() => { + this.realm.delete(object); + }); + expect(this.realm.objects(WithCounterSchema.name).length).equals(0); + + expect(() => counter.value).to.throw("Accessing object which has been invalidated or deleted"); + }); + + it("throws when setting a mixed to a counter via object creation", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { mixed: counter }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + }); + + it("throws when setting a mixed to a counter via object setter", function (this: RealmContext) { + const { counter, objectWithMixed } = this.realm.write(() => { + const counter = this.realm.create(WithCounterSchema.name, { + counter: 10, + }).counter; + const objectWithMixed = this.realm.create(WithMixedSchema.name, { mixed: 20 }); + + return { counter, objectWithMixed }; + }); + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + objectWithMixed.mixed = counter; + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(objectWithMixed.mixed).equals(20); + }); + + it("throws when adding a counter to mixed collections via object creation", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + expect(counter.value).equals(10); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { list: [counter] }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { dictionary: { key: counter } }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { set: [counter] }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { mixed: [counter] }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + + expect(() => { + this.realm.write(() => { + this.realm.create(WithMixedSchema.name, { mixed: { key: counter } }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(this.realm.objects(WithMixedSchema.name).length).equals(0); + }); + + it("throws when adding a counter to mixed collections via setters", function (this: RealmContext) { + const { counter, list, dictionary, set, objectWithMixed } = this.realm.write(() => { + const counter = this.realm.create(WithCounterSchema.name, { + counter: 10, + }).counter; + const list = this.realm.create(WithMixedSchema.name, { list: [20] }).list; + const dictionary = this.realm.create(WithMixedSchema.name, { dictionary: { key: 20 } }).dictionary; + const set = this.realm.create(WithMixedSchema.name, { set: [20] }).set; + const objectWithMixed = this.realm.create(WithMixedSchema.name, { mixed: [20] }); + + return { counter, list, dictionary, set, objectWithMixed }; + }); + expectCounter(counter); + expectRealmList(list); + expectRealmDictionary(dictionary); + expectRealmSet(set); + expect(counter.value).equals(10); + expect(list[0]).equals(20); + expect(dictionary.key).equals(20); + expect(set[0]).equals(20); + + // Collections OF Mixed + expect(() => { + this.realm.write(() => { + list[0] = counter; + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(list[0]).equals(20); + + expect(() => { + this.realm.write(() => { + list.push(counter); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(list.length).equals(1); + expect(list[0]).equals(20); + + expect(() => { + this.realm.write(() => { + dictionary.key = counter; + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expectKeys(dictionary, ["key"]); + expect(dictionary.key).equals(20); + + expect(() => { + this.realm.write(() => { + dictionary.set({ newKey: counter }); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expectKeys(dictionary, ["key"]); + expect(dictionary.key).equals(20); + + expect(() => { + this.realm.write(() => { + set.add(counter); + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(set[0]).equals(20); + expect(set.has(counter.value)).to.be.false; + + // Collections IN Mixed + expect(() => { + this.realm.write(() => { + objectWithMixed.mixed = [counter]; + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expect(list.length).equals(1); + expect(list[0]).equals(20); + + expect(() => { + this.realm.write(() => { + objectWithMixed.mixed = { newKey: counter }; + }); + }).to.throw("Using a Counter as a Mixed value is not supported"); + expectKeys(dictionary, ["key"]); + expect(dictionary.key).equals(20); + }); + + it("throws when using counter as query argument", function (this: RealmContext) { + const { counter } = this.realm.write(() => { + return this.realm.create(WithCounterSchema.name, { + counter: 10, + }); + }); + expectCounter(counter); + + const objects = this.realm.objects(WithCounterSchema.name); + expect(objects.length).equals(1); + + expect(() => { + objects.filtered("counter = $0", counter); + }).to.throw("Using a Counter as a query argument is not supported. Use 'Counter.value'"); + }); + }); +}); diff --git a/integration-tests/tests/src/tests/dynamic-schema-updates.ts b/integration-tests/tests/src/tests/dynamic-schema-updates.ts index 951bf4c04c..efefac6489 100644 --- a/integration-tests/tests/src/tests/dynamic-schema-updates.ts +++ b/integration-tests/tests/src/tests/dynamic-schema-updates.ts @@ -66,6 +66,7 @@ describe("realm._updateSchema", () => { name: "myField", optional: false, type: "string", + presentation: undefined, default: undefined, }, }, @@ -100,6 +101,7 @@ describe("realm._updateSchema", () => { name: "age", optional: false, type: "int", + presentation: undefined, default: undefined, }, friends: { @@ -109,6 +111,7 @@ describe("realm._updateSchema", () => { objectType: "Dog", optional: false, type: "list", + presentation: undefined, default: undefined, }, name: { @@ -117,6 +120,7 @@ describe("realm._updateSchema", () => { name: "name", optional: false, type: "string", + presentation: undefined, default: undefined, }, owner: { @@ -126,6 +130,7 @@ describe("realm._updateSchema", () => { objectType: "Person", optional: true, type: "object", + presentation: undefined, default: undefined, }, }, diff --git a/integration-tests/tests/src/tests/mixed.ts b/integration-tests/tests/src/tests/mixed.ts index 26597caa2b..67ec4b83ec 100644 --- a/integration-tests/tests/src/tests/mixed.ts +++ b/integration-tests/tests/src/tests/mixed.ts @@ -20,6 +20,7 @@ import Realm, { BSON, ObjectSchema } from "realm"; import { expect } from "chai"; import { openRealmBefore, openRealmBeforeEach } from "../hooks"; +import { expectRealmDictionary, expectRealmList, expectRealmResults } from "../utils/expects"; interface ISingle { a: Realm.Mixed; @@ -367,18 +368,6 @@ describe("Mixed", () => { ], }); - 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}. diff --git a/integration-tests/tests/src/tests/observable.ts b/integration-tests/tests/src/tests/observable.ts index 37777d2f00..1fbe8c2d02 100644 --- a/integration-tests/tests/src/tests/observable.ts +++ b/integration-tests/tests/src/tests/observable.ts @@ -375,6 +375,7 @@ describe("Observable", () => { name: { name: "name", type: "string", + presentation: undefined, optional: false, indexed: false, mapTo: "name", diff --git a/integration-tests/tests/src/utils/expects.ts b/integration-tests/tests/src/utils/expects.ts new file mode 100644 index 0000000000..cb2a5c3d39 --- /dev/null +++ b/integration-tests/tests/src/utils/expects.ts @@ -0,0 +1,40 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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 Realm, { Counter } from "realm"; + +export function expectRealmList(value: unknown): asserts value is Realm.List { + expect(value).instanceOf(Realm.List); +} + +export function expectRealmDictionary(value: unknown): asserts value is Realm.Dictionary { + expect(value).instanceOf(Realm.Dictionary); +} + +export function expectRealmResults(value: unknown): asserts value is Realm.Results { + expect(value).instanceOf(Realm.Results); +} + +export function expectRealmSet(value: unknown): asserts value is Realm.Set { + expect(value).instanceOf(Realm.Set); +} + +export function expectCounter(value: unknown): asserts value is Counter { + expect(value).to.be.instanceOf(Counter); +} diff --git a/package-lock.json b/package-lock.json index 46d7ebc827..3f9d64e49e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26277,7 +26277,7 @@ } }, "packages/realm": { - "version": "12.9.0", + "version": "12.10.0-rc.0", "hasInstallScript": true, "license": "apache-2.0", "dependencies": { diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index b7c654ff6d..65e4973c06 100644 --- a/packages/realm/bindgen/js_opt_in_spec.yml +++ b/packages/realm/bindgen/js_opt_in_spec.yml @@ -302,6 +302,7 @@ classes: - get_any - set_any - set_collection + - add_int - get_linked_object - get_backlink_count - get_backlink_view diff --git a/packages/realm/bindgen/vendor/realm-core b/packages/realm/bindgen/vendor/realm-core index 1516230010..6bebc40a03 160000 --- a/packages/realm/bindgen/vendor/realm-core +++ b/packages/realm/bindgen/vendor/realm-core @@ -1 +1 @@ -Subproject commit 1516230010bdd93584635e2edf1ffb6ab64f9ec4 +Subproject commit 6bebc40a03ca4144050bc672a6cd86c2286caa32 diff --git a/packages/realm/package.json b/packages/realm/package.json index 23706115b6..2781884a5d 100644 --- a/packages/realm/package.json +++ b/packages/realm/package.json @@ -1,6 +1,6 @@ { "name": "realm", - "version": "12.9.0", + "version": "12.10.0-rc.0", "description": "Realm by MongoDB is an offline-first mobile database: an alternative to SQLite and key-value stores", "license": "apache-2.0", "homepage": "https://www.mongodb.com/docs/realm/", diff --git a/packages/realm/src/ClassMap.ts b/packages/realm/src/ClassMap.ts index 76a40ec2b8..3153561167 100644 --- a/packages/realm/src/ClassMap.ts +++ b/packages/realm/src/ClassMap.ts @@ -153,7 +153,7 @@ export class ClassMap { // Get the uninitialized property map const { properties } = getClassHelpers(constructor as typeof RealmObject); // Initialize the property map, now that all classes have helpers set - properties.initialize(objectSchema, defaults, { + properties.initialize(objectSchema, canonicalObjectSchema, defaults, { realm, getClassHelpers: (name: string) => this.getHelpers(name), }); diff --git a/packages/realm/src/Counter.ts b/packages/realm/src/Counter.ts new file mode 100644 index 0000000000..9050070d61 --- /dev/null +++ b/packages/realm/src/Counter.ts @@ -0,0 +1,148 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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 { IllegalConstructorError, Realm, UpdateMode, assert, binding } from "./internal"; + +const REALM = Symbol("Counter#realm"); +const OBJ = Symbol("Counter#obj"); +const COLUMN_KEY = Symbol("Counter#columnKey"); + +/** + * A logical counter representation for performing numeric updates that need + * to be synchronized as sequentially consistent events rather than individual + * reassignments of the number. + * + * For instance, offline Client 1 and Client 2 which both see `Counter.value` + * as `0`, can both call `Counter.increment(1)`. Once online, the value will + * converge to `2`. + * + * ### Counter types are *not* supported as: + * + * - `Mixed` values + * - Primary keys + * - Inside collections + * - Query arguments for placeholders (e.g. `$0`) in {@link Realm.Results.filtered | filtered()} + * - If you need to use the value of the `Counter` when filtering, use `Counter.value`. + * + * ### Declaring a counter + * + * A property schema is declared as either: + * - `"counter"` + * - `{ type: "int", presentation: "counter" }` + * + * ### Creating a counter + * + * Use a `number` when creating your counter on a {@link Realm.Object}. + * + * ```typescript + * realm.write(() => { + * realm.create(MyObject, { _id: "123", counter: 0 }); + * }); + * ``` + * + * ### Updating the count + * + * Use the instance methods to update the underlying count. + * + * ### Nullability + * + * The above property schema can be extended to allow a nullable counter. + * A `Counter` never stores `null` values itself, but the counter property + * on the {@link Realm.Object} (e.g. `myRealmObject.myCounter`) can be `null`. + * + * To create a counter from a previously `null` value, or to reset a nullable + * counter to `null`, use {@link UpdateMode.Modified} or {@link UpdateMode.All}. + * + * ```typescript + * realm.write(() => { + * realm.create(MyObject, { _id: "123", counter: 0 }, UpdateMode.Modified); + * }); + * ``` + */ +export class Counter { + /** @internal */ + private readonly [REALM]: Realm; + /** @internal */ + private readonly [OBJ]: binding.Obj; + /** @internal */ + private readonly [COLUMN_KEY]: binding.ColKey; + + /** @internal */ + constructor(realm: Realm, obj: binding.Obj, columnKey: binding.ColKey) { + if (!(realm instanceof Realm) || !(obj instanceof binding.Obj)) { + throw new IllegalConstructorError("Counter"); + } + + this[REALM] = realm; + this[OBJ] = obj; + this[COLUMN_KEY] = columnKey; + } + + /** + * The current count. + */ + get value(): number { + try { + return Number(this[OBJ].getAny(this[COLUMN_KEY])); + } catch (err) { + // Throw a custom error message instead of Core's. + assert.isValid(this[OBJ]); + throw err; + } + } + + /** @internal */ + set value(_: number) { + throw new Error("To update the value, use the methods on the Counter."); + } + + /** + * Increment the count. + * @param by The value to increment by. (Default: `1`) + */ + increment(by = 1): void { + assert.inTransaction(this[REALM]); + assert.integer(by, "by"); + this[OBJ].addInt(this[COLUMN_KEY], binding.Int64.numToInt(by)); + } + + /** + * Decrement the count. + * @param by The value to decrement by. (Default: `1`) + */ + decrement(by = 1): void { + assert.inTransaction(this[REALM]); + // Assert that it is a number here despite calling into `increment` in order to + // report the type provided by the user, rather than e.g. NaN or 0 due to negation. + assert.integer(by, "by"); + this.increment(-by); + } + + /** + * Reset the count. + * @param value The value to reset the count to. + * @warning + * Unlike {@link Counter.increment | increment} and {@link Counter.decrement | decrement}, + * setting the count behaves like regular individual updates to the underlying value. + */ + set(value: number): void { + assert.inTransaction(this[REALM]); + assert.integer(value, "value"); + this[OBJ].setAny(this[COLUMN_KEY], binding.Int64.numToInt(value)); + } +} diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index b2d9e0b0ea..6851b1b01a 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -43,25 +43,27 @@ import { } from "./internal"; /** - * The update mode to use when creating an object that already exists. + * The update mode to use when creating an object that already exists, + * which is determined by a matching primary key. */ export enum UpdateMode { /** - * Objects are only created. If an existing object exists, an exception is thrown. + * Objects are only created. If an existing object exists (determined by a + * matching primary key), an exception is thrown. */ Never = "never", /** - * If an existing object exists, only properties where the value has actually - * changed will be updated. This improves notifications and server side - * performance but also have implications for how changes across devices are - * merged. For most use cases, the behavior will match the intuitive behavior - * of how changes should be merged, but if updating an entire object is - * considered an atomic operation, this mode should not be used. + * If an existing object exists (determined by a matching primary key), only + * properties where the value has actually changed will be updated. This improves + * notifications and server side performance but also have implications for how + * changes across devices are merged. For most use cases, the behavior will match + * the intuitive behavior of how changes should be merged, but if updating an + * entire object is considered an atomic operation, this mode should not be used. */ Modified = "modified", /** - * If an existing object is found, all properties provided will be updated, - * any other properties will remain unchanged. + * If an existing object exists (determined by a matching primary key), all + * properties provided will be updated, any other properties will remain unchanged. */ All = "all", } @@ -218,24 +220,27 @@ export class RealmObject PropertyAccessor; const ACCESSOR_FACTORIES: Partial> = { + [binding.PropertyType.Int](options) { + const { realm, columnKey, presentation, optional } = options; + + if (presentation === "counter") { + return { + get(obj) { + return obj.getAny(columnKey) === null ? null : new Counter(realm, obj, columnKey); + }, + set(obj, value, isCreating) { + // We only allow resetting a counter this way (e.g. realmObject.counter = 5) + // when it is first created, or when resetting a nullable/optional counter + // to `null`, or when a nullable counter was previously `null`. + const isAllowed = + isCreating || (optional && (value === null || value === undefined || obj.getAny(columnKey) === null)); + + if (isAllowed) { + defaultSet(options)(obj, value); + } else { + throw new Error( + "You can only reset a Counter instance when initializing a previously " + + "null Counter or resetting a nullable Counter to null. To update the " + + "value of the Counter, use its instance methods.", + ); + } + }, + }; + } else { + return { + get: defaultGet(options), + set: defaultSet(options), + }; + } + }, [binding.PropertyType.Object](options) { const { columnKey, @@ -370,6 +407,7 @@ export function createPropertyHelpers(property: PropertyContext, options: Helper objectType: property.objectType, objectSchemaName: property.objectSchemaName, optional: !!(property.type & binding.PropertyType.Nullable), + presentation: property.presentation, }; if (collectionType) { return getPropertyHelpers(collectionType, { diff --git a/packages/realm/src/PropertyMap.ts b/packages/realm/src/PropertyMap.ts index 44ddb1775e..9decf1d920 100644 --- a/packages/realm/src/PropertyMap.ts +++ b/packages/realm/src/PropertyMap.ts @@ -16,7 +16,14 @@ // //////////////////////////////////////////////////////////////////////////// -import { HelperOptions, PropertyHelpers, binding, createPropertyHelpers } from "./internal"; +import { + CanonicalObjectSchema, + HelperOptions, + PropertyHelpers, + assert, + binding, + createPropertyHelpers, +} from "./internal"; class UninitializedPropertyMapError extends Error { constructor() { @@ -35,7 +42,12 @@ export class PropertyMap { private nameByColumnKeyString: Map = new Map(); private _names: string[] = []; - public initialize(objectSchema: binding.ObjectSchema, defaults: Record, options: HelperOptions) { + public initialize( + objectSchema: binding.ObjectSchema, + canonicalObjectSchema: CanonicalObjectSchema, + defaults: Record, + options: HelperOptions, + ) { const { name: objectSchemaName, persistedProperties, computedProperties } = objectSchema; this.objectSchemaName = objectSchemaName; const properties = [...persistedProperties, ...computedProperties]; @@ -45,10 +57,17 @@ export class PropertyMap { const embedded = property.objectType ? options.getClassHelpers(property.objectType).objectSchema.tableType === binding.TableType.Embedded : false; - const helpers = createPropertyHelpers({ ...property, embedded, objectSchemaName }, options); + + const canonicalPropertySchema = canonicalObjectSchema.properties[propertyName]; + assert(canonicalPropertySchema, `Expected '${propertyName}' to exist on the CanonicalObjectSchema.`); + const helpers = createPropertyHelpers( + { ...property, embedded, objectSchemaName, presentation: canonicalPropertySchema.presentation }, + options, + ); // Allow users to override the default value of properties const defaultValue = defaults[propertyName]; helpers.default = typeof defaultValue !== "undefined" ? defaultValue : helpers.default; + return [propertyName, helpers]; }), ); diff --git a/packages/realm/src/Realm.ts b/packages/realm/src/Realm.ts index 9582e405d2..0fb15010d0 100644 --- a/packages/realm/src/Realm.ts +++ b/packages/realm/src/Realm.ts @@ -37,6 +37,7 @@ import { LoggerCallback2, MigrationCallback, ObjectSchema, + PresentationPropertyTypeName, ProgressRealmPromise, REALM, RealmEvent, @@ -80,6 +81,7 @@ type RealmSchemaExtra = Record; type ObjectSchemaExtra = { constructor?: RealmObjectConstructor; defaults: Record; + presentations: Record; // objectTypes: Record; }; @@ -398,17 +400,26 @@ export class Realm { } } - private static extractSchemaExtras(schemas: CanonicalObjectSchema[]): RealmSchemaExtra { - return Object.fromEntries( - schemas.map((schema) => { - const defaults = Object.fromEntries( - Object.entries(schema.properties).map(([name, property]) => { - return [name, property.default]; - }), - ); - return [schema.name, { defaults, constructor: schema.ctor }]; - }), - ); + private static extractRealmSchemaExtras(schemas: CanonicalObjectSchema[]): RealmSchemaExtra { + const extras: RealmSchemaExtra = {}; + for (const schema of schemas) { + extras[schema.name] = this.extractObjectSchemaExtras(schema); + } + + return extras; + } + + /** @internal */ + private static extractObjectSchemaExtras(schema: CanonicalObjectSchema): ObjectSchemaExtra { + const defaults: Record = {}; + const presentations: Record = {}; + + for (const [name, propertySchema] of Object.entries(schema.properties)) { + defaults[name] = propertySchema.default; + presentations[name] = propertySchema.presentation; + } + + return { constructor: schema.ctor, defaults, presentations }; } /** @internal */ @@ -417,7 +428,7 @@ export class Realm { bindingConfig: binding.RealmConfig_Relaxed; } { const normalizedSchema = config.schema && normalizeRealmSchema(config.schema); - const schemaExtras = Realm.extractSchemaExtras(normalizedSchema || []); + const schemaExtras = Realm.extractRealmSchemaExtras(normalizedSchema || []); const path = Realm.determinePath(config); const { fifoFilesFallbackPath, shouldCompact, inMemory } = config; const bindingSchema = normalizedSchema && toBindingSchema(normalizedSchema); @@ -653,6 +664,7 @@ export class Realm { } for (const property of Object.values(objectSchema.properties)) { property.default = extras ? extras.defaults[property.name] : undefined; + property.presentation = extras ? extras.presentations[property.name] : undefined; } } return schemas; @@ -1228,6 +1240,7 @@ export namespace Realm { export import AnyList = internal.AnyList; export import AnyRealmObject = internal.AnyRealmObject; export import AnyResults = internal.AnyResults; + export import AnySet = internal.AnySet; export import AnyUser = internal.AnyUser; export import ApiKey = internal.ApiKey; export import AppChangeCallback = internal.AppChangeCallback; @@ -1262,6 +1275,7 @@ export namespace Realm { export import ConfigurationWithSync = internal.ConfigurationWithSync; export import ConnectionNotificationCallback = internal.ConnectionNotificationCallback; export import ConnectionState = internal.ConnectionState; + export import Counter = internal.Counter; export import Credentials = internal.Credentials; export import DefaultFunctionsFactory = internal.DefaultFunctionsFactory; export import DefaultUserProfileData = internal.DefaultUserProfileData; @@ -1304,6 +1318,7 @@ export namespace Realm { export import OpenRealmTimeOutBehavior = internal.OpenRealmTimeOutBehavior; export import OrderedCollection = internal.OrderedCollection; export import PartitionSyncConfiguration = internal.PartitionSyncConfiguration; + export import PresentationPropertyTypeName = internal.PresentationPropertyTypeName; export import PrimaryKey = internal.PrimaryKey; export import PrimitivePropertyTypeName = internal.PrimitivePropertyTypeName; export import ProgressDirection = internal.ProgressDirection; @@ -1330,6 +1345,7 @@ export namespace Realm { export import SessionState = internal.SessionState; export import SessionStopPolicy = internal.SessionStopPolicy; export import Set = internal.RealmSet; + export import ShorthandPrimitivePropertyTypeName = internal.ShorthandPrimitivePropertyTypeName; export import SortDescriptor = internal.SortDescriptor; export import SSLConfiguration = internal.SSLConfiguration; export import SSLVerifyCallback = internal.SSLVerifyCallback; diff --git a/packages/realm/src/TypeHelpers.ts b/packages/realm/src/TypeHelpers.ts index 5b8cc1d6bf..53615517aa 100644 --- a/packages/realm/src/TypeHelpers.ts +++ b/packages/realm/src/TypeHelpers.ts @@ -20,10 +20,12 @@ import { BSON, ClassHelpers, Collection, + Counter, Dictionary, INTERNAL, List, ObjCreator, + PresentationPropertyTypeName, REALM, Realm, RealmObject, @@ -93,6 +95,7 @@ export type TypeOptions = { optional: boolean; objectType: string | undefined; objectSchemaName: string | undefined; + presentation?: PresentationPropertyTypeName; getClassHelpers(nameOrTableKey: string | binding.TableKey): ClassHelpers; }; @@ -114,6 +117,7 @@ export function mixedToBinding( value: unknown, { isQueryArg } = { isQueryArg: false }, ): binding.MixedArg { + const displayedType = isQueryArg ? "a query argument" : "a Mixed value"; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { // Fast track pass through for the most commonly used types return value; @@ -123,13 +127,17 @@ export function mixedToBinding( 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.`); + throw new Error(`Using an embedded object (${value.constructor.name}) as ${displayedType} 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 RealmSet || value instanceof Set) { - throw new Error(`Using a ${value.constructor.name} as a Mixed value is not supported.`); + throw new Error(`Using a ${value.constructor.name} as ${displayedType} is not supported.`); + } else if (value instanceof Counter) { + let errMessage = `Using a Counter as ${displayedType} is not supported.`; + errMessage += isQueryArg ? " Use 'Counter.value'." : ""; + throw new Error(errMessage); } else { if (isQueryArg) { if (value instanceof Collection || Array.isArray(value)) { @@ -217,13 +225,18 @@ function nullPassthrough TypeHelpers> = { - [binding.PropertyType.Int]({ optional }) { + [binding.PropertyType.Int]({ presentation, optional }) { return { toBinding: nullPassthrough((value) => { if (typeof value === "number") { return binding.Int64.numToInt(value); } else if (binding.Int64.isInt(value)) { return value; + } else if (value instanceof Counter) { + if (presentation !== "counter") { + throw new Error(`Counters can only be used when 'counter' is declared in the property schema.`); + } + return binding.Int64.numToInt(value.value); } else { throw new TypeAssertionError("a number or bigint", value); } diff --git a/packages/realm/src/Types.ts b/packages/realm/src/Types.ts index 9414128521..a7d278e275 100644 --- a/packages/realm/src/Types.ts +++ b/packages/realm/src/Types.ts @@ -32,6 +32,7 @@ export namespace Types { export import Decimal128 = internal.BSON.Decimal128; export import ObjectId = internal.BSON.ObjectId; export import UUID = internal.BSON.UUID; + export import Counter = internal.Counter; export type Date = GlobalDate; export const Date = GlobalDate; diff --git a/packages/realm/src/Unmanaged.ts b/packages/realm/src/Unmanaged.ts index e8af6fa278..3d01ce22e1 100644 --- a/packages/realm/src/Unmanaged.ts +++ b/packages/realm/src/Unmanaged.ts @@ -16,7 +16,7 @@ // //////////////////////////////////////////////////////////////////////////// -import type { AnyRealmObject, Collection, Dictionary, List, Realm } from "./internal"; +import type { AnyRealmObject, Collection, Counter, Dictionary, List, Realm, RealmSet } from "./internal"; /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- We define these once to avoid using "any" through the code */ export type AnyCollection = Collection; @@ -24,15 +24,21 @@ export type AnyCollection = Collection; export type AnyDictionary = Dictionary; /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- We define these once to avoid using "any" through the code */ export type AnyList = List; +/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- We define these once to avoid using "any" through the code */ +export type AnySet = RealmSet; type ExtractPropertyNamesOfType = { [K in keyof T]: T[K] extends PropType ? K : never; }[keyof T]; +type ExtractPropertyNamesOfTypeExcludingNullability = { + [K in keyof T]: Exclude extends PropType ? K : never; +}[keyof T]; + /** * Exchanges properties defined as {@link List} with an optional {@link Array}. */ -type RealmListsRemappedModelPart = { +type RealmListRemappedModelPart = { [K in ExtractPropertyNamesOfType]?: T[K] extends List ? Array> : never; }; @@ -45,6 +51,20 @@ type RealmDictionaryRemappedModelPart = { : never; }; +/** + * Exchanges properties defined as {@link RealmSet} with an optional {@link Array}. + */ +type RealmSetRemappedModelPart = { + [K in ExtractPropertyNamesOfType]?: T[K] extends RealmSet ? Array> : never; +}; + +/** + * Exchanges properties defined as a {@link Counter} with a `number`. + */ +type RealmCounterRemappedModelPart = { + [K in ExtractPropertyNamesOfTypeExcludingNullability]?: Counter | number | Exclude; +}; + /** Omits all properties of a model which are not defined by the schema */ export type OmittedRealmTypes = Omit< T, @@ -53,6 +73,7 @@ export type OmittedRealmTypes = Omit< | ExtractPropertyNamesOfType // TODO: Figure out the use-case for this | ExtractPropertyNamesOfType | ExtractPropertyNamesOfType + | ExtractPropertyNamesOfTypeExcludingNullability >; /** Make all fields optional except those specified in K */ @@ -68,7 +89,10 @@ type OmittedRealmTypesWithRequired; /** Remaps realm types to "simpler" types (arrays and objects) */ -type RemappedRealmTypes = RealmListsRemappedModelPart & RealmDictionaryRemappedModelPart; +type RemappedRealmTypes = RealmListRemappedModelPart & + RealmDictionaryRemappedModelPart & + RealmSetRemappedModelPart & + RealmCounterRemappedModelPart; /** * Joins `T` stripped of all keys which value extends {@link Collection} and all inherited from {@link Realm.Object}, diff --git a/packages/realm/src/assert.ts b/packages/realm/src/assert.ts index 2e8f2c7065..e8e705cc31 100644 --- a/packages/realm/src/assert.ts +++ b/packages/realm/src/assert.ts @@ -64,6 +64,10 @@ assert.number = (value: unknown, target?: string): asserts value is number => { assert(typeof value === "number", () => new TypeAssertionError("a number", value, target)); }; +assert.integer = (value: unknown, target?: string): asserts value is number => { + assert(Number.isInteger(value), () => new TypeAssertionError("an integer", value, target)); +}; + assert.numericString = (value: unknown, target?: string) => { assert.string(value); assert(/^-?\d+$/.test(value), () => new TypeAssertionError("a numeric string", value, target)); diff --git a/packages/realm/src/errors.ts b/packages/realm/src/errors.ts index 2de188b60a..2a4ef009e8 100644 --- a/packages/realm/src/errors.ts +++ b/packages/realm/src/errors.ts @@ -45,6 +45,14 @@ export class TypeAssertionError extends AssertionError { return typeof value; } else if (typeof value === "function") { return `a function or class named ${value.name}`; + } else if (typeof value === "number") { + if (Number.isNaN(value)) { + return "NaN"; + } else if (!Number.isInteger(value)) { + return "a decimal number"; + } else { + return "a number"; + } } else { return "a " + typeof value; } diff --git a/packages/realm/src/internal.ts b/packages/realm/src/internal.ts index 2ca1fde964..49f6f8b6a1 100644 --- a/packages/realm/src/internal.ts +++ b/packages/realm/src/internal.ts @@ -67,6 +67,7 @@ export * from "./Results"; export * from "./List"; export * from "./Set"; export * from "./Dictionary"; +export * from "./Counter"; export * from "./Types"; export * from "./GeoSpatial"; diff --git a/packages/realm/src/schema/normalize.ts b/packages/realm/src/schema/normalize.ts index 6dc5bd6297..cfee8c6df7 100644 --- a/packages/realm/src/schema/normalize.ts +++ b/packages/realm/src/schema/normalize.ts @@ -22,6 +22,7 @@ import { CollectionPropertyTypeName, ObjectSchema, ObjectSchemaParseError, + PresentationPropertyTypeName, PrimitivePropertyTypeName, PropertiesTypes, PropertySchema, @@ -74,6 +75,14 @@ const COLLECTION_SHORTHAND_TO_NAME: Readonly> = { const COLLECTION_SUFFIX_LENGTH = "[]".length; +const PRESENTATION_TYPES = new Set(["counter"]); + +const PRESENTATION_TO_REALM_TYPE: Readonly> = { + counter: "int", +}; + +const OPTIONAL_MARKER = "?"; + function isPrimitive(type: string | undefined): type is PrimitivePropertyTypeName { return PRIMITIVE_TYPES.has(type as PrimitivePropertyTypeName); } @@ -82,6 +91,10 @@ function isCollection(type: string | undefined): type is CollectionPropertyTypeN return COLLECTION_TYPES.has(type as CollectionPropertyTypeName); } +function isPresentationType(type: string | undefined): type is PresentationPropertyTypeName { + return PRESENTATION_TYPES.has(type as PresentationPropertyTypeName); +} + function isUserDefined(type: string | undefined): type is UserTypeName { return !!type && !(isPrimitive(type) || isCollection(type) || type === "object" || type === "linkingObjects"); } @@ -180,6 +193,7 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can let type = ""; let objectType: string | undefined; + let presentation: PresentationPropertyTypeName | undefined; let optional: boolean | undefined; if (hasCollectionSuffix(propertySchema)) { @@ -193,10 +207,10 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can assert(!isNestedCollection, propError(info, "Nested collections are not supported.")); } - if (propertySchema.endsWith("?")) { + if (propertySchema.endsWith(OPTIONAL_MARKER)) { optional = true; - propertySchema = propertySchema.substring(0, propertySchema.length - 1); + propertySchema = propertySchema.substring(0, propertySchema.length - OPTIONAL_MARKER.length); assert(propertySchema.length > 0, propError(info, "The type must be specified. (Examples: 'int?' and 'int?[]')")); const usingOptionalOnCollection = hasCollectionSuffix(propertySchema); @@ -209,6 +223,11 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can ); } + if (isPresentationType(propertySchema)) { + presentation = propertySchema; + propertySchema = PRESENTATION_TO_REALM_TYPE[propertySchema]; + } + if (isPrimitive(propertySchema)) { if (isCollection(type)) { objectType = propertySchema; @@ -238,6 +257,15 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can } } + switch (presentation) { + case "counter": + // If `type` is not an int at this point, a collection shorthand is used. + assert(type === "int", propError(info, "Counters cannot be used in collections.")); + break; + default: + break; + } + if (isAlwaysOptional(type, objectType)) { optional = true; } else if (isNeverOptional(type, objectType)) { @@ -260,6 +288,7 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can }; // Add optional properties only if defined (tests expect no 'undefined' properties) if (objectType !== undefined) normalizedSchema.objectType = objectType; + if (presentation !== undefined) normalizedSchema.presentation = presentation; return normalizedSchema; } @@ -270,7 +299,7 @@ function normalizePropertySchemaShorthand(info: PropertyInfoUsingShorthand): Can */ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): CanonicalPropertySchema { const { propertySchema } = info; - const { type, objectType, property, default: defaultValue } = propertySchema; + const { type, objectType, presentation, property, default: defaultValue } = propertySchema; let { optional, indexed } = propertySchema; assert(type.length > 0, propError(info, "'type' must be specified.")); @@ -301,6 +330,14 @@ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): Canonical assert(property === undefined, propError(info, "'property' can only be specified if 'type' is 'linkingObjects'.")); } + switch (presentation) { + case "counter": + assert(type === "int", propError(info, "Counters can only be used when 'type' is 'int'.")); + break; + default: + break; + } + if (isAlwaysOptional(type, objectType)) { const displayed = type === "mixed" || objectType === "mixed" @@ -319,6 +356,7 @@ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): Canonical if (info.isPrimaryKey) { assert(indexed !== false, propError(info, "Primary keys must always be indexed.")); assert(indexed !== "full-text", propError(info, "Primary keys cannot be full-text indexed.")); + assert(presentation !== "counter", propError(info, "Counters cannot be primary keys.")); indexed = true; } @@ -332,6 +370,7 @@ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): Canonical // Add optional properties only if defined (tests expect no 'undefined' properties) if (objectType !== undefined) normalizedSchema.objectType = objectType; + if (presentation !== undefined) normalizedSchema.presentation = presentation; if (property !== undefined) normalizedSchema.property = property; if (defaultValue !== undefined) normalizedSchema.default = defaultValue; @@ -374,26 +413,35 @@ function assertNotUsingShorthand(input: string | undefined, info: PropertyInfo): } const shorthands = extractShorthands(input); - assert( - shorthands.length === 0, - propError( - info, - `Cannot use shorthand '${shorthands.join("' and '")}' in 'type' or 'objectType' when defining property objects.`, - ), - ); + let message = + `Cannot use shorthand '${shorthands.all.join("' and '")}' in 'type' ` + + "or 'objectType' when defining property objects."; + + if (shorthands.presentationType) { + message += ` To use presentation types such as '${shorthands.presentationType}', use the field 'presentation'.`; + } + assert(shorthands.all.length === 0, propError(info, message)); } /** * Extract the shorthand markers used in the input. */ -function extractShorthands(input: string): string[] { - const shorthands: string[] = []; +function extractShorthands(input: string) { + const shorthands: { all: string[]; presentationType?: PresentationPropertyTypeName } = { all: [] }; + if (hasCollectionSuffix(input)) { - shorthands.push(input.substring(input.length - COLLECTION_SUFFIX_LENGTH)); + shorthands.all.push(input.substring(input.length - COLLECTION_SUFFIX_LENGTH)); input = input.substring(0, input.length - COLLECTION_SUFFIX_LENGTH); } - if (input.endsWith("?")) { - shorthands.push("?"); + + if (input.endsWith(OPTIONAL_MARKER)) { + shorthands.all.push(OPTIONAL_MARKER); + input = input.substring(0, input.length - OPTIONAL_MARKER.length); + } + + if (isPresentationType(input)) { + shorthands.all.push(input); + shorthands.presentationType = input; } return shorthands; diff --git a/packages/realm/src/schema/types.ts b/packages/realm/src/schema/types.ts index c4a3160d39..ce5e952f7c 100644 --- a/packages/realm/src/schema/types.ts +++ b/packages/realm/src/schema/types.ts @@ -55,6 +55,12 @@ export type PrimitivePropertyTypeName = | "mixed" | "uuid"; +/** + * The names of the supported Realm primitive property types and their + * presentation types used in {@link PropertySchemaShorthand}. + */ +export type ShorthandPrimitivePropertyTypeName = PrimitivePropertyTypeName | PresentationPropertyTypeName; + /** * The names of the supported Realm collection property types. */ @@ -65,6 +71,15 @@ export type CollectionPropertyTypeName = "list" | "dictionary" | "set"; */ export type RelationshipPropertyTypeName = "object" | "linkingObjects"; +/** + * The names of the supported Realm presentation property types. + * + * Some types can be presented as a type different from the database type. + * For instance, an integer that should behave like a logical counter is + * presented as a `"counter"` type. + */ +export type PresentationPropertyTypeName = "counter"; + /** * The name of a user-defined Realm object type. It must contain at least 1 character * and cannot be a {@link PropertyTypeName}. (Unicode is supported.) @@ -89,10 +104,11 @@ export type IndexedType = boolean | "full-text"; export type CanonicalPropertySchema = { name: string; type: PropertyTypeName; + objectType?: string; + presentation?: PresentationPropertyTypeName; optional: boolean; indexed: IndexedType; mapTo: string; // TODO: Make this optional and leave it out when it equals the name - objectType?: string; property?: string; default?: unknown; }; @@ -165,7 +181,7 @@ export type CanonicalPropertiesTypes"` | `""`) + * - ({@link ShorthandPrimitivePropertyTypeName} | {@link UserTypeName})(`"?"` | `""`)(`"[]"` | `"{}"` | `"<>"` | `""`) * - `"?"` * - The marker to declare an optional type or an optional element in a collection * if the type itself is a collection. Can only be used when declaring property @@ -204,8 +220,8 @@ export type ObjectSchemaProperty = PropertySchema; * @see {@link PropertySchemaShorthand} for a shorthand representation of a property * schema. * @see {@link PropertySchemaStrict} for a precise type definition of the requirements - * with the allowed combinations. This type is less strict in order to provide a more - * user-friendly option due to misleading TypeScript error messages when working with + * with the allowed combinations. {@link PropertySchema} is less strict in order to provide + * a more user-friendly option due to misleading TypeScript error messages when working with * the strict type. This type is currently recommended for that reason, but the strict * type is provided as guidance. (Exact errors will always be shown when creating a * {@link Realm} instance if the schema is invalid.) @@ -220,6 +236,20 @@ export type PropertySchema = { * or the specific Realm object type if `type` is a {@link RelationshipPropertyTypeName}. */ objectType?: PrimitivePropertyTypeName | UserTypeName; + /** + * The presentation type of the property. + * + * Some types can be presented as a type different from the database type. + * For instance, an integer that should behave like a logical counter is + * presented as a `"counter"` type. + * @example + * // A counter + * { + * type: "int", + * presentation: "counter", + * } + */ + presentation?: PresentationPropertyTypeName; /** * The name of the property of the object specified in `objectType` that creates this * link. (Can only be set for linking objects.) @@ -257,6 +287,7 @@ export type PropertySchema = { * Keys used in the property schema that are common among all variations of {@link PropertySchemaStrict}. */ export type PropertySchemaCommon = { + presentation: never; indexed?: IndexedType; mapTo?: string; default?: unknown; @@ -265,7 +296,7 @@ export type PropertySchemaCommon = { /** * The strict schema for specifying the type of a specific Realm object property. * - * Unlike the less strict {@link PropertySchema}, this type precisely defines the type + * Unlike the less strict {@link PropertySchema}, the strict type precisely defines the type * requirements and their allowed combinations; however, TypeScript error messages tend * to be more misleading. {@link PropertySchema} is recommended for that reason, but the * strict type is provided as guidance. @@ -275,8 +306,13 @@ export type PropertySchemaCommon = { export type PropertySchemaStrict = PropertySchemaCommon & ( | { - type: Exclude; + type: Exclude; + optional?: boolean; + } + | { + type: "int"; optional?: boolean; + presentation?: "counter"; } | { type: "mixed"; diff --git a/packages/realm/src/schema/validate.ts b/packages/realm/src/schema/validate.ts index 1e17e5df34..1104fec4f0 100644 --- a/packages/realm/src/schema/validate.ts +++ b/packages/realm/src/schema/validate.ts @@ -49,6 +49,7 @@ const OBJECT_SCHEMA_KEYS = new Set([ const PROPERTY_SCHEMA_KEYS = new Set([ "type", "objectType", + "presentation", "property", "default", "optional", @@ -150,11 +151,14 @@ export function validatePropertySchema( ): asserts propertySchema is PropertySchema { try { assert.object(propertySchema, `'${propertyName}' on '${objectName}'`, { allowArrays: false }); - const { type, objectType, optional, property, indexed, mapTo } = propertySchema; + const { type, objectType, presentation, optional, property, indexed, mapTo } = propertySchema; assert.string(type, `'${propertyName}.type' on '${objectName}'`); if (objectType !== undefined) { assert.string(objectType, `'${propertyName}.objectType' on '${objectName}'`); } + if (presentation !== undefined) { + assert.string(presentation, `'${propertyName}.presentation' on '${objectName}'`); + } if (optional !== undefined) { assert.boolean(optional, `'${propertyName}.optional' on '${objectName}'`); } diff --git a/packages/realm/src/tests/milestone-2.test.ts b/packages/realm/src/tests/milestone-2.test.ts index cf56f759e2..ab10d5a6e0 100644 --- a/packages/realm/src/tests/milestone-2.test.ts +++ b/packages/realm/src/tests/milestone-2.test.ts @@ -45,6 +45,7 @@ describe("Milestone #2", () => { name: { name: "name", type: "string", + presentation: undefined, optional: false, indexed: true, mapTo: "name", @@ -57,6 +58,7 @@ describe("Milestone #2", () => { name: "age", optional: true, type: "int", + presentation: undefined, }, bestFriend: { indexed: false, @@ -65,6 +67,7 @@ describe("Milestone #2", () => { optional: true, type: "object", objectType: "Person", + presentation: undefined, default: undefined, }, }, diff --git a/packages/realm/src/tests/schema-normalization.test.ts b/packages/realm/src/tests/schema-normalization.test.ts index 3db27d009e..e873976be7 100644 --- a/packages/realm/src/tests/schema-normalization.test.ts +++ b/packages/realm/src/tests/schema-normalization.test.ts @@ -27,982 +27,1130 @@ const OBJECT_NAME = "MyObject"; const PROPERTY_NAME = "prop"; describe("normalizePropertySchema", () => { - // ------------------------------------------------------------------------ - // Valid string notation - // ------------------------------------------------------------------------ - - describe("using valid string notation", () => { - // ----------------- - // string - // ----------------- - - itNormalizes("string", { - type: "string", - optional: false, - }); - - itNormalizes("string?", { - type: "string", - optional: true, - }); - - itNormalizes("string[]", { - type: "list", - objectType: "string", - optional: false, - }); - - itNormalizes("string?[]", { - type: "list", - objectType: "string", - optional: true, - }); - - itNormalizes("string{}", { - type: "dictionary", - objectType: "string", - optional: false, - }); - - itNormalizes("string?{}", { - type: "dictionary", - objectType: "string", - optional: true, - }); - - itNormalizes("string<>", { - type: "set", - objectType: "string", - optional: false, - }); - - itNormalizes("string?<>", { - type: "set", - objectType: "string", - optional: true, - }); - - // ----------------- - // mixed - // ----------------- - - itNormalizes("mixed", { - type: "mixed", - optional: true, - }); - - itNormalizes("mixed?", { - type: "mixed", - optional: true, - }); - - itNormalizes("mixed[]", { - type: "list", - objectType: "mixed", - optional: true, - }); - - itNormalizes("mixed?[]", { - type: "list", - objectType: "mixed", - optional: true, - }); - - itNormalizes("mixed{}", { - type: "dictionary", - objectType: "mixed", - optional: true, - }); - - itNormalizes("mixed?{}", { - type: "dictionary", - objectType: "mixed", - optional: true, - }); - - itNormalizes("mixed<>", { - type: "set", - objectType: "mixed", - optional: true, - }); - - itNormalizes("mixed?<>", { - type: "set", - objectType: "mixed", - optional: true, - }); - - // ------------------------- - // User-defined type: Person - // ------------------------- - - itNormalizes("Person", { - type: "object", - objectType: "Person", - optional: true, - }); - - itNormalizes("Person?", { - type: "object", - objectType: "Person", - optional: true, - }); + describe("Shorthand notation", () => { + describe("Valid combinations", () => { + describe("'string' & collection combinations", () => { + itNormalizes("string", { + type: "string", + optional: false, + }); + + itNormalizes("string?", { + type: "string", + optional: true, + }); + + itNormalizes("string[]", { + type: "list", + objectType: "string", + optional: false, + }); + + itNormalizes("string?[]", { + type: "list", + objectType: "string", + optional: true, + }); + + itNormalizes("string{}", { + type: "dictionary", + objectType: "string", + optional: false, + }); + + itNormalizes("string?{}", { + type: "dictionary", + objectType: "string", + optional: true, + }); + + itNormalizes("string<>", { + type: "set", + objectType: "string", + optional: false, + }); + + itNormalizes("string?<>", { + type: "set", + objectType: "string", + optional: true, + }); + }); - itNormalizes("Person[]", { - type: "list", - objectType: "Person", - optional: false, - }); + describe("'mixed' & collection combinations", () => { + itNormalizes("mixed", { + type: "mixed", + optional: true, + }); + + itNormalizes("mixed?", { + type: "mixed", + optional: true, + }); + + itNormalizes("mixed[]", { + type: "list", + objectType: "mixed", + optional: true, + }); + + itNormalizes("mixed?[]", { + type: "list", + objectType: "mixed", + optional: true, + }); + + itNormalizes("mixed{}", { + type: "dictionary", + objectType: "mixed", + optional: true, + }); + + itNormalizes("mixed?{}", { + type: "dictionary", + objectType: "mixed", + optional: true, + }); + + itNormalizes("mixed<>", { + type: "set", + objectType: "mixed", + optional: true, + }); + + itNormalizes("mixed?<>", { + type: "set", + objectType: "mixed", + optional: true, + }); + }); - itNormalizes("Person<>", { - type: "set", - objectType: "Person", - optional: false, - }); + describe("'counter'", () => { + itNormalizes("counter", { + type: "int", + presentation: "counter", + optional: false, + }); + + itNormalizes("counter?", { + type: "int", + presentation: "counter", + optional: true, + }); + }); - itNormalizes("Person{}", { - type: "dictionary", - objectType: "Person", - optional: true, - }); + describe("User-defined 'Person' & collection combinations", () => { + itNormalizes("Person", { + type: "object", + objectType: "Person", + optional: true, + }); + + itNormalizes("Person?", { + type: "object", + objectType: "Person", + optional: true, + }); + + itNormalizes("Person[]", { + type: "list", + objectType: "Person", + optional: false, + }); + + itNormalizes("Person<>", { + type: "set", + objectType: "Person", + optional: false, + }); + + itNormalizes("Person{}", { + type: "dictionary", + objectType: "Person", + optional: true, + }); + + itNormalizes("Person?{}", { + type: "dictionary", + objectType: "Person", + optional: true, + }); + }); - itNormalizes("Person?{}", { - type: "dictionary", - objectType: "Person", - optional: true, + describe("Indexed and primary key combinations", () => { + itNormalizes( + "string", + { + type: "string", + indexed: true, + optional: false, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + "string?", + { + type: "string", + indexed: true, + optional: true, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + "mixed?", + { + type: "mixed", + indexed: true, + optional: true, + }, + { isPrimaryKey: true }, + ); + }); }); - // ------------------------- - // Indexed & Primary Keys - // ------------------------- - - itNormalizes( - "string", - { - type: "string", - indexed: true, - optional: false, - }, - { isPrimaryKey: true }, - ); - - itNormalizes( - "string?", - { - type: "string", - indexed: true, - optional: true, - }, - { isPrimaryKey: true }, - ); - - itNormalizes( - "mixed?", - { - type: "mixed", - indexed: true, - optional: true, - }, - { isPrimaryKey: true }, - ); - }); - - // ------------------------------------------------------------------------ - // Invalid string notation - // ------------------------------------------------------------------------ - - describe("using invalid string notation", () => { - itThrowsWhenNormalizing("", "The type must be specified"); - - itThrowsWhenNormalizing("?", "The type must be specified"); - - itThrowsWhenNormalizing("?[]", "The type must be specified"); - - itThrowsWhenNormalizing("[]", "The element type must be specified"); - - itThrowsWhenNormalizing("{}", "The element type must be specified"); - - itThrowsWhenNormalizing("<>", "The element type must be specified"); - - itThrowsWhenNormalizing("[][]", "Nested collections are not supported"); - - itThrowsWhenNormalizing("{}[]", "Nested collections are not supported"); - - itThrowsWhenNormalizing("[]<>", "Nested collections are not supported"); - - itThrowsWhenNormalizing("int[][]", "Nested collections are not supported"); - - itThrowsWhenNormalizing( - "[]?", - "Collections cannot be optional. To allow elements of the collection to be optional, use '?' after the element type", - ); - - itThrowsWhenNormalizing( - "int[]?", - "Collections cannot be optional. To allow elements of the collection to be optional, use '?' after the element type", - ); - - itThrowsWhenNormalizing("list", "Cannot use the collection name"); - - itThrowsWhenNormalizing("dictionary", "Cannot use the collection name"); - - itThrowsWhenNormalizing("set", "Cannot use the collection name"); - - itThrowsWhenNormalizing("list[]", "Cannot use the collection name"); - - itThrowsWhenNormalizing( - "Person?[]", - "User-defined types in lists and sets are always non-optional and cannot be made optional. Remove '?' or change the type.", - ); - - itThrowsWhenNormalizing( - "Person?<>", - "User-defined types in lists and sets are always non-optional and cannot be made optional. Remove '?' or change the type.", - ); - - itThrowsWhenNormalizing( - "object", - "To define a relationship, use either 'MyObjectType' or { type: 'object', objectType: 'MyObjectType' }", - ); - - itThrowsWhenNormalizing( - "linkingObjects", - "To define an inverse relationship, use { type: 'linkingObjects', objectType: 'MyObjectType', property: 'myObjectTypesProperty' }", - ); - }); - - // ------------------------------------------------------------------------ - // Valid object notation - // ------------------------------------------------------------------------ - - describe("using valid object notation", () => { - // ----------------- - // string - // ----------------- - - itNormalizes( - { - type: "string", - }, - { - type: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "string", - optional: false, - }, - { - type: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "string", - optional: true, - }, - { - type: "string", - optional: true, - }, - ); - - itNormalizes( - { - type: "list", - objectType: "string", - }, - { - type: "list", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "list", - objectType: "string", - optional: false, - }, - { - type: "list", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "list", - objectType: "string", - optional: true, - }, - { - type: "list", - objectType: "string", - optional: true, - }, - ); - - itNormalizes( - { - type: "dictionary", - objectType: "string", - }, - { - type: "dictionary", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "dictionary", - objectType: "string", - optional: false, - }, - { - type: "dictionary", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "dictionary", - objectType: "string", - optional: true, - }, - { - type: "dictionary", - objectType: "string", - optional: true, - }, - ); - - itNormalizes( - { - type: "set", - objectType: "string", - }, - { - type: "set", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "set", - objectType: "string", - optional: false, - }, - { - type: "set", - objectType: "string", - optional: false, - }, - ); - - itNormalizes( - { - type: "set", - objectType: "string", - optional: true, - }, - { - type: "set", - objectType: "string", - optional: true, - }, - ); - - // ----------------- - // mixed - // ----------------- - - itNormalizes( - { - type: "mixed", - }, - { - type: "mixed", - optional: true, - }, - ); + describe("Invalid shorthand notation", () => { + itThrowsWhenNormalizing("", "The type must be specified"); - itNormalizes( - { - type: "mixed", - optional: true, - }, - { - type: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("?", "The type must be specified"); - itNormalizes( - { - type: "list", - objectType: "mixed", - }, - { - type: "list", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("?[]", "The type must be specified"); - itNormalizes( - { - type: "list", - objectType: "mixed", - optional: true, - }, - { - type: "list", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("[]", "The element type must be specified"); - itNormalizes( - { - type: "dictionary", - objectType: "mixed", - }, - { - type: "dictionary", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("{}", "The element type must be specified"); - itNormalizes( - { - type: "dictionary", - objectType: "mixed", - optional: true, - }, - { - type: "dictionary", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("<>", "The element type must be specified"); - itNormalizes( - { - type: "set", - objectType: "mixed", - }, - { - type: "set", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("[][]", "Nested collections are not supported"); - itNormalizes( - { - type: "set", - objectType: "mixed", - optional: true, - }, - { - type: "set", - objectType: "mixed", - optional: true, - }, - ); + itThrowsWhenNormalizing("{}[]", "Nested collections are not supported"); - // ------------------------- - // User-defined type: Person - // ------------------------- - - itNormalizes( - { - type: "object", - objectType: "Person", - }, - { - type: "object", - objectType: "Person", - optional: true, - }, - ); + itThrowsWhenNormalizing("[]<>", "Nested collections are not supported"); - itNormalizes( - { - type: "object", - objectType: "Person", - optional: true, - }, - { - type: "object", - objectType: "Person", - optional: true, - }, - ); + itThrowsWhenNormalizing("int[][]", "Nested collections are not supported"); - itNormalizes( - { - type: "list", - objectType: "Person", - }, - { - type: "list", - objectType: "Person", - optional: false, - }, - ); + itThrowsWhenNormalizing( + "[]?", + "Collections cannot be optional. To allow elements of the collection to be optional, use '?' after the element type", + ); - itNormalizes( - { - type: "list", - objectType: "Person", - optional: false, - }, - { - type: "list", - objectType: "Person", - optional: false, - }, - ); + itThrowsWhenNormalizing( + "int[]?", + "Collections cannot be optional. To allow elements of the collection to be optional, use '?' after the element type", + ); - itNormalizes( - { - type: "set", - objectType: "Person", - }, - { - type: "set", - objectType: "Person", - optional: false, - }, - ); + itThrowsWhenNormalizing("list", "Cannot use the collection name"); - itNormalizes( - { - type: "set", - objectType: "Person", - optional: false, - }, - { - type: "set", - objectType: "Person", - optional: false, - }, - ); + itThrowsWhenNormalizing("dictionary", "Cannot use the collection name"); - itNormalizes( - { - type: "dictionary", - objectType: "Person", - }, - { - type: "dictionary", - objectType: "Person", - optional: true, - }, - ); + itThrowsWhenNormalizing("set", "Cannot use the collection name"); - itNormalizes( - { - type: "dictionary", - objectType: "Person", - optional: true, - }, - { - type: "dictionary", - objectType: "Person", - optional: true, - }, - ); + itThrowsWhenNormalizing("list[]", "Cannot use the collection name"); - itNormalizes( - { - type: "linkingObjects", - objectType: "Person", - property: "tasks", - }, - { - type: "linkingObjects", - objectType: "Person", - property: "tasks", - optional: false, - }, - ); + itThrowsWhenNormalizing( + "Person?[]", + "User-defined types in lists and sets are always non-optional and cannot be made optional. Remove '?' or change the type.", + ); - itNormalizes( - { - type: "linkingObjects", - objectType: "Person", - property: "tasks", - optional: false, - }, - { - type: "linkingObjects", - objectType: "Person", - property: "tasks", - optional: false, - }, - ); + itThrowsWhenNormalizing( + "Person?<>", + "User-defined types in lists and sets are always non-optional and cannot be made optional. Remove '?' or change the type.", + ); - // ------------------------- - // Indexed & Primary Keys - // ------------------------- - - itNormalizes( - { - type: "string", - }, - { - type: "string", - indexed: true, - optional: false, - }, - { isPrimaryKey: true }, - ); + itThrowsWhenNormalizing( + "object", + "To define a relationship, use either 'MyObjectType' or { type: 'object', objectType: 'MyObjectType' }", + ); - itNormalizes( - { - type: "string", - indexed: true, - }, - { - type: "string", - indexed: true, - optional: false, - }, - { isPrimaryKey: true }, - ); + itThrowsWhenNormalizing( + "linkingObjects", + "To define an inverse relationship, use { type: 'linkingObjects', objectType: 'MyObjectType', property: 'myObjectTypesProperty' }", + ); - itNormalizes( - { - type: "string", - indexed: true, - }, - { - type: "string", - indexed: true, - optional: false, - }, - { isPrimaryKey: false }, - ); + itThrowsWhenNormalizing("counter[]", "Counters cannot be used in collections"); - itNormalizes( - { - type: "string", - indexed: "full-text", - }, - { - type: "string", - indexed: "full-text", - optional: false, - }, - { isPrimaryKey: false }, - ); + itThrowsWhenNormalizing("counter?[]", "Counters cannot be used in collections"); - itNormalizes( - { - type: "string", - optional: true, - }, - { - type: "string", - indexed: true, - optional: true, - }, - { isPrimaryKey: true }, - ); + itThrowsWhenNormalizing("counter{}", "Counters cannot be used in collections"); - itNormalizes( - { - type: "mixed", - }, - { - type: "mixed", - indexed: true, - optional: true, - }, - { isPrimaryKey: true }, - ); + itThrowsWhenNormalizing("counter?{}", "Counters cannot be used in collections"); - itNormalizes( - { - type: "mixed", - optional: true, - }, - { - type: "mixed", - indexed: true, - optional: true, - }, - { isPrimaryKey: true }, - ); + itThrowsWhenNormalizing("counter<>", "Counters cannot be used in collections"); - itNormalizes( - { - type: "list", - objectType: "string", - indexed: true, - }, - { - type: "list", - objectType: "string", - indexed: true, - optional: false, - }, - { isPrimaryKey: false }, - ); + itThrowsWhenNormalizing("counter?<>", "Counters cannot be used in collections"); + }); }); - // ------------------------------------------------------------------------ - // Invalid object notation - // ------------------------------------------------------------------------ - - describe("using invalid object notation", () => { - itThrowsWhenNormalizing( - { - // @ts-expect-error Passing in the wrong type - type: "", - }, - "'type' must be specified", - ); - - itThrowsWhenNormalizing( - { - type: "string", - objectType: "string", - }, - "'objectType' cannot be defined when 'type' is 'string'", - ); - - itThrowsWhenNormalizing( - { - type: "list", - }, - "A list must contain only primitive or user-defined types specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "dictionary", - }, - "A dictionary must contain only primitive or user-defined types specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "set", - }, - "A set must contain only primitive or user-defined types specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "list", - objectType: "list", - }, - "A list must contain only primitive or user-defined types specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - // @ts-expect-error Passing in the wrong type - type: "Person", - }, - "If you meant to define a relationship, use { type: 'object', objectType: 'Person' } or { type: 'linkingObjects', objectType: 'Person', property: 'The Person property' }", - ); - - itThrowsWhenNormalizing( - { - type: "object", - }, - "A user-defined type must be specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "object", - objectType: "string", - }, - "A user-defined type must be specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "object", - objectType: "Person", - optional: false, - }, - "User-defined types as standalone objects and in dictionaries are always optional and cannot be made non-optional", - ); - - itThrowsWhenNormalizing( - { - type: "mixed", - optional: false, - }, - "'mixed' types are always optional and cannot be made non-optional", - ); - - itThrowsWhenNormalizing( - { - type: "list", - objectType: "mixed", - optional: false, - }, - "'mixed' types are always optional and cannot be made non-optional", - ); - - itThrowsWhenNormalizing( - { - type: "list", - objectType: "Person", - optional: true, - }, - "User-defined types in lists and sets are always non-optional and cannot be made optional", - ); - - itThrowsWhenNormalizing( - { - type: "set", - objectType: "Person", - optional: true, - }, - "User-defined types in lists and sets are always non-optional and cannot be made optional", - ); - - itThrowsWhenNormalizing( - { - type: "dictionary", - objectType: "Person", - optional: false, - }, - "User-defined types as standalone objects and in dictionaries are always optional and cannot be made non-optional", - ); - - itThrowsWhenNormalizing( - { - type: "linkingObjects", - }, - "A user-defined type must be specified through 'objectType'", - ); - - itThrowsWhenNormalizing( - { - type: "linkingObjects", - objectType: "Person", - }, - "The linking object's property name must be specified through 'property'", - ); - - itThrowsWhenNormalizing( - { - type: "linkingObjects", - objectType: "Person", - property: "", - }, - "The linking object's property name must be specified through 'property'", - ); - - itThrowsWhenNormalizing( - { - type: "linkingObjects", - objectType: "Person", - property: "tasks", - optional: true, - }, - "User-defined types in lists and sets are always non-optional and cannot be made optional", - ); - - itThrowsWhenNormalizing( - { - type: "object", - objectType: "Person", - property: "tasks", - }, - "'property' can only be specified if 'type' is 'linkingObjects'", - ); - - // ------------------------- - // Combining with shorthand - // ------------------------- + describe("Object notation", () => { + describe("Valid object notation", () => { + describe("'string' & collection combinations", () => { + itNormalizes( + { + type: "string", + }, + { + type: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "string", + optional: false, + }, + { + type: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "string", + optional: true, + }, + { + type: "string", + optional: true, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "string", + }, + { + type: "list", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "string", + optional: false, + }, + { + type: "list", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "string", + optional: true, + }, + { + type: "list", + objectType: "string", + optional: true, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "string", + }, + { + type: "dictionary", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "string", + optional: false, + }, + { + type: "dictionary", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "string", + optional: true, + }, + { + type: "dictionary", + objectType: "string", + optional: true, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "string", + }, + { + type: "set", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "string", + optional: false, + }, + { + type: "set", + objectType: "string", + optional: false, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "string", + optional: true, + }, + { + type: "set", + objectType: "string", + optional: true, + }, + ); + }); - itThrowsWhenNormalizing( - { - // @ts-expect-error Passing in the wrong type - type: "int?", - }, - "Cannot use shorthand '?' in 'type' or 'objectType' when defining property objects", - ); + describe("'mixed' & collection combinations", () => { + itNormalizes( + { + type: "mixed", + }, + { + type: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "mixed", + optional: true, + }, + { + type: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "mixed", + }, + { + type: "list", + objectType: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "mixed", + optional: true, + }, + { + type: "list", + objectType: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "mixed", + }, + { + type: "dictionary", + objectType: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "mixed", + optional: true, + }, + { + type: "dictionary", + objectType: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "mixed", + }, + { + type: "set", + objectType: "mixed", + optional: true, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "mixed", + optional: true, + }, + { + type: "set", + objectType: "mixed", + optional: true, + }, + ); + }); - itThrowsWhenNormalizing( - { - // @ts-expect-error Passing in the wrong type - type: "int?[]", - }, - "Cannot use shorthand '[]' and '?' in 'type' or 'objectType' when defining property objects", - ); + describe("'counter'", () => { + itNormalizes( + { + type: "int", + presentation: "counter", + }, + { + type: "int", + presentation: "counter", + optional: false, + }, + ); + + itNormalizes( + { + type: "int", + presentation: "counter", + optional: false, + }, + { + type: "int", + presentation: "counter", + optional: false, + }, + ); + + itNormalizes( + { + type: "int", + presentation: "counter", + optional: true, + }, + { + type: "int", + presentation: "counter", + optional: true, + }, + ); + }); - itThrowsWhenNormalizing( - { - type: "int", - objectType: "[]", - }, - "Cannot use shorthand '[]' in 'type' or 'objectType' when defining property objects", - ); + describe("User-defined 'Person' & collection combinations", () => { + itNormalizes( + { + type: "object", + objectType: "Person", + }, + { + type: "object", + objectType: "Person", + optional: true, + }, + ); + + itNormalizes( + { + type: "object", + objectType: "Person", + optional: true, + }, + { + type: "object", + objectType: "Person", + optional: true, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "Person", + }, + { + type: "list", + objectType: "Person", + optional: false, + }, + ); + + itNormalizes( + { + type: "list", + objectType: "Person", + optional: false, + }, + { + type: "list", + objectType: "Person", + optional: false, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "Person", + }, + { + type: "set", + objectType: "Person", + optional: false, + }, + ); + + itNormalizes( + { + type: "set", + objectType: "Person", + optional: false, + }, + { + type: "set", + objectType: "Person", + optional: false, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "Person", + }, + { + type: "dictionary", + objectType: "Person", + optional: true, + }, + ); + + itNormalizes( + { + type: "dictionary", + objectType: "Person", + optional: true, + }, + { + type: "dictionary", + objectType: "Person", + optional: true, + }, + ); + + itNormalizes( + { + type: "linkingObjects", + objectType: "Person", + property: "tasks", + }, + { + type: "linkingObjects", + objectType: "Person", + property: "tasks", + optional: false, + }, + ); + + itNormalizes( + { + type: "linkingObjects", + objectType: "Person", + property: "tasks", + optional: false, + }, + { + type: "linkingObjects", + objectType: "Person", + property: "tasks", + optional: false, + }, + ); + }); - itThrowsWhenNormalizing( - { - type: "int", - objectType: "?[]", - }, - "Cannot use shorthand '[]' and '?' in 'type' or 'objectType' when defining property objects", - ); + describe("Indexed and primary key combinations", () => { + itNormalizes( + { + type: "string", + }, + { + type: "string", + indexed: true, + optional: false, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + { + type: "string", + indexed: true, + }, + { + type: "string", + indexed: true, + optional: false, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + { + type: "string", + indexed: true, + }, + { + type: "string", + indexed: true, + optional: false, + }, + { isPrimaryKey: false }, + ); + + itNormalizes( + { + type: "string", + indexed: "full-text", + }, + { + type: "string", + indexed: "full-text", + optional: false, + }, + { isPrimaryKey: false }, + ); + + itNormalizes( + { + type: "string", + optional: true, + }, + { + type: "string", + indexed: true, + optional: true, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + { + type: "mixed", + }, + { + type: "mixed", + indexed: true, + optional: true, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + { + type: "mixed", + optional: true, + }, + { + type: "mixed", + indexed: true, + optional: true, + }, + { isPrimaryKey: true }, + ); + + itNormalizes( + { + type: "list", + objectType: "string", + indexed: true, + }, + { + type: "list", + objectType: "string", + indexed: true, + optional: false, + }, + { isPrimaryKey: false }, + ); + }); + }); - itThrowsWhenNormalizing( - { - type: "list", - objectType: "int?", - }, - "Cannot use shorthand '?' in 'type' or 'objectType' when defining property objects", - ); + describe("Invalid object notation", () => { + itThrowsWhenNormalizing( + { + // @ts-expect-error Passing in the wrong type. + type: "", + }, + "'type' must be specified", + ); + + itThrowsWhenNormalizing( + { + type: "string", + objectType: "string", + }, + "'objectType' cannot be defined when 'type' is 'string'", + ); + + itThrowsWhenNormalizing( + { + type: "list", + }, + "A list must contain only primitive or user-defined types specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "dictionary", + }, + "A dictionary must contain only primitive or user-defined types specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "set", + }, + "A set must contain only primitive or user-defined types specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "list", + }, + "A list must contain only primitive or user-defined types specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + // @ts-expect-error Passing in the wrong type. + type: "Person", + }, + "If you meant to define a relationship, use { type: 'object', objectType: 'Person' } or { type: 'linkingObjects', objectType: 'Person', property: 'The Person property' }", + ); + + itThrowsWhenNormalizing( + { + type: "object", + }, + "A user-defined type must be specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "object", + objectType: "string", + }, + "A user-defined type must be specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "object", + objectType: "Person", + optional: false, + }, + "User-defined types as standalone objects and in dictionaries are always optional and cannot be made non-optional", + ); + + itThrowsWhenNormalizing( + { + type: "mixed", + optional: false, + }, + "'mixed' types are always optional and cannot be made non-optional", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "mixed", + optional: false, + }, + "'mixed' types are always optional and cannot be made non-optional", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "Person", + optional: true, + }, + "User-defined types in lists and sets are always non-optional and cannot be made optional", + ); + + itThrowsWhenNormalizing( + { + type: "set", + objectType: "Person", + optional: true, + }, + "User-defined types in lists and sets are always non-optional and cannot be made optional", + ); + + itThrowsWhenNormalizing( + { + type: "dictionary", + objectType: "Person", + optional: false, + }, + "User-defined types as standalone objects and in dictionaries are always optional and cannot be made non-optional", + ); + + itThrowsWhenNormalizing( + { + type: "linkingObjects", + }, + "A user-defined type must be specified through 'objectType'", + ); + + itThrowsWhenNormalizing( + { + type: "linkingObjects", + objectType: "Person", + }, + "The linking object's property name must be specified through 'property'", + ); + + itThrowsWhenNormalizing( + { + type: "linkingObjects", + objectType: "Person", + property: "", + }, + "The linking object's property name must be specified through 'property'", + ); + + itThrowsWhenNormalizing( + { + type: "linkingObjects", + objectType: "Person", + property: "tasks", + optional: true, + }, + "User-defined types in lists and sets are always non-optional and cannot be made optional", + ); + + itThrowsWhenNormalizing( + { + type: "object", + objectType: "Person", + property: "tasks", + }, + "'property' can only be specified if 'type' is 'linkingObjects'", + ); + + describe("Mixing shorthand inside object notation", () => { + itThrowsWhenNormalizing( + { + // @ts-expect-error Passing in the wrong type. + type: "int?", + }, + "Cannot use shorthand '?' in 'type' or 'objectType' when defining property objects", + ); + + itThrowsWhenNormalizing( + { + // @ts-expect-error Passing in the wrong type. + type: "int?[]", + }, + "Cannot use shorthand '[]' and '?' in 'type' or 'objectType' when defining property objects", + ); + + itThrowsWhenNormalizing( + { + type: "int", + objectType: "[]", + }, + "Cannot use shorthand '[]' in 'type' or 'objectType' when defining property objects", + ); + + itThrowsWhenNormalizing( + { + type: "int", + objectType: "?[]", + }, + "Cannot use shorthand '[]' and '?' in 'type' or 'objectType' when defining property objects", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "int?", + }, + "Cannot use shorthand '?' in 'type' or 'objectType' when defining property objects", + ); + + itThrowsWhenNormalizing( + { + // @ts-expect-error Passing in the wrong type. + type: "counter", + }, + "Cannot use shorthand 'counter' in 'type' or 'objectType' when defining property objects. To use presentation types such as 'counter', use the field 'presentation'", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "counter?", + }, + "Cannot use shorthand '?' and 'counter' in 'type' or 'objectType' when defining property objects. To use presentation types such as 'counter', use the field 'presentation'", + ); + }); - // ------------------------- - // Indexed & Primary Keys - // ------------------------- - - itThrowsWhenNormalizing( - { - type: "string", - indexed: false, - optional: false, - }, - "Primary keys must always be indexed.", - { isPrimaryKey: true }, - ); + describe("'counter' & collection combinations", () => { + itThrowsWhenNormalizing( + { + type: "list", + objectType: "int", + presentation: "counter", + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "int", + presentation: "counter", + optional: false, + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "list", + objectType: "int", + presentation: "counter", + optional: true, + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "dictionary", + objectType: "int", + presentation: "counter", + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "dictionary", + objectType: "int", + presentation: "counter", + optional: false, + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "dictionary", + objectType: "int", + presentation: "counter", + optional: true, + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "set", + objectType: "int", + presentation: "counter", + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "set", + objectType: "int", + presentation: "counter", + optional: false, + }, + "Counters can only be used when 'type' is 'int'", + ); + + itThrowsWhenNormalizing( + { + type: "set", + objectType: "int", + presentation: "counter", + optional: true, + }, + "Counters can only be used when 'type' is 'int'", + ); + }); - itThrowsWhenNormalizing( - { - type: "string", - indexed: "full-text", - }, - "Primary keys cannot be full-text indexed.", - { isPrimaryKey: true }, - ); + describe("Indexed and primary key combinations", () => { + itThrowsWhenNormalizing( + { + type: "string", + indexed: false, + optional: false, + }, + "Primary keys must always be indexed.", + { isPrimaryKey: true }, + ); + + itThrowsWhenNormalizing( + { + type: "string", + indexed: "full-text", + }, + "Primary keys cannot be full-text indexed.", + { isPrimaryKey: true }, + ); + + itThrowsWhenNormalizing( + { + type: "int", + presentation: "counter", + }, + "Counters cannot be primary keys.", + { isPrimaryKey: true }, + ); + }); + }); }); }); @@ -1025,9 +1173,7 @@ function itNormalizes( expected: Partial, { isPrimaryKey } = { isPrimaryKey: false }, ): void { - it(`normalizes ${inspect(input, { compact: true, breakLength: Number.MAX_SAFE_INTEGER })} ${ - isPrimaryKey ? "(primary key)" : "" - }`, () => { + it(`normalizes ${stringifyToOneLine(input, isPrimaryKey)}`, () => { const result = normalizePropertySchema({ objectName: OBJECT_NAME, propertyName: PROPERTY_NAME, @@ -1056,9 +1202,7 @@ function itThrowsWhenNormalizing( errMessage: string, { isPrimaryKey } = { isPrimaryKey: false }, ): void { - it(`throws when normalizing ${inspect(input, { compact: true, breakLength: Number.MAX_SAFE_INTEGER })} ${ - isPrimaryKey ? "(primary key)" : "" - }`, () => { + it(`throws when normalizing ${stringifyToOneLine(input, isPrimaryKey)}`, () => { const normalizeFn = () => normalizePropertySchema({ objectName: OBJECT_NAME, @@ -1072,3 +1216,8 @@ function itThrowsWhenNormalizing( ); }); } + +function stringifyToOneLine(input: PropertySchema | PropertySchemaShorthand, isPrimaryKey: boolean) { + const stringified = inspect(input, { compact: true, breakLength: Number.MAX_SAFE_INTEGER }); + return stringified + (isPrimaryKey ? " (primary key)" : ""); +} diff --git a/packages/realm/src/tests/schema-validation.test.ts b/packages/realm/src/tests/schema-validation.test.ts index 98aeb71fcf..ff819051a0 100644 --- a/packages/realm/src/tests/schema-validation.test.ts +++ b/packages/realm/src/tests/schema-validation.test.ts @@ -28,11 +28,7 @@ const NOT_A_BOOLEAN = 0; const NOT_AN_OBJECT = 0; describe("validateObjectSchema", () => { - // ------------------------------------------------------------------------ - // Valid shape of input - // ------------------------------------------------------------------------ - - describe("using valid shape of input", () => { + describe("Valid shape of input", () => { itValidates("an object with all top-level fields defined", { name: "", primaryKey: "", @@ -55,11 +51,7 @@ describe("validateObjectSchema", () => { }); }); - // ------------------------------------------------------------------------ - // Invalid shape of input - // ------------------------------------------------------------------------ - - describe("using invalid shape of input", () => { + describe("Invalid shape of input", () => { itThrowsWhenValidating("an array", [], "Expected 'object schema' to be an object, got an array"); itThrowsWhenValidating("'null'", null, "Expected 'object schema' to be an object, got null"); @@ -142,14 +134,11 @@ describe("validateObjectSchema", () => { }); describe("validatePropertySchema", () => { - // ------------------------------------------------------------------------ - // Valid shape of input - // ------------------------------------------------------------------------ - - describe("using valid shape of input", () => { + describe("Valid shape of input", () => { itValidates("an object with all fields defined", { type: "", objectType: "", + presentation: "", optional: true, property: "", indexed: true, @@ -160,6 +149,7 @@ describe("validatePropertySchema", () => { itValidates("an object with required fields defined and optional fields set to 'undefined'", { type: "", objectType: undefined, + presentation: undefined, optional: undefined, property: undefined, indexed: undefined, @@ -172,11 +162,7 @@ describe("validatePropertySchema", () => { }); }); - // ------------------------------------------------------------------------ - // Invalid shape of input - // ------------------------------------------------------------------------ - - describe("using invalid shape of input", () => { + describe("Invalid shape of input", () => { itThrowsWhenValidating( "an array", [], @@ -208,6 +194,15 @@ describe("validatePropertySchema", () => { `Expected '${PROPERTY_NAME}.objectType' on '${OBJECT_NAME}' to be a string, got a number`, ); + itThrowsWhenValidating( + "an object with invalid type for property 'presentation'", + { + type: "", + presentation: NOT_A_STRING, + }, + `Expected '${PROPERTY_NAME}.presentation' on '${OBJECT_NAME}' to be a string, got a number`, + ); + itThrowsWhenValidating( "an object with invalid type for property 'optional'", {