diff --git a/integration-tests/tests/src/tests.ts b/integration-tests/tests/src/tests.ts index 6bd005fd52..be3b5d4791 100644 --- a/integration-tests/tests/src/tests.ts +++ b/integration-tests/tests/src/tests.ts @@ -63,6 +63,7 @@ import "./tests/objects"; import "./tests/observable"; import "./tests/queries"; import "./tests/realm-constructor"; +import "./tests/relaxed-schema"; import "./tests/results"; import "./tests/schema"; import "./tests/serialization"; diff --git a/integration-tests/tests/src/tests/relaxed-schema.ts b/integration-tests/tests/src/tests/relaxed-schema.ts new file mode 100644 index 0000000000..3a2ecc7e71 --- /dev/null +++ b/integration-tests/tests/src/tests/relaxed-schema.ts @@ -0,0 +1,213 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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. +// +//////////////////////////////////////////////////////////////////////////// + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { expect } from "chai"; +import { PersonSchema, Person } from "../schemas/person-and-dogs"; +import { openRealmBeforeEach } from "../hooks"; +import { BSON } from "realm"; + +describe("Relaxed schema", () => { + openRealmBeforeEach({ relaxedSchema: true, schema: [PersonSchema] }); + + it("can open a Realm with a relaxed schema", function (this: Mocha.Context & RealmContext) { + expect(this.realm).not.null; + }); + + it("can add an object to a Realm with a relaxed schema", function (this: Mocha.Context & RealmContext) { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + + expect(this.realm.objects(PersonSchema.name).length).equals(1); + }); + + it("can modify an existing property of an object in a Realm with a relaxed schema", function (this: Mocha.Context & + RealmContext) { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + + this.realm.write(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(joe).not.null; + joe.age = 25; + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const olderJoe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(olderJoe.age).equals(25n); // TODO: why BigInt and not Number? + }); + + [ + ["primitive", 1234], + ["data", new ArrayBuffer(10)], + ["decimal128", 12n], + ["objectId", new BSON.ObjectID()], + ["uuid", new BSON.UUID()], + // ["linkingObjects", "linkingObjects"], + // ["list", ["123", "123", "12"]], + // [ + // "dictionary", + // { + // dictionary: { + // windows: 3, + // apples: 3, + // }, + // }, + // ], + ].forEach(([typeName, valueToSet]) => { + describe(`with ${typeName}`, () => { + let setValue: any; + + beforeEach(function (this: Mocha.Context & RealmContext) { + if (valueToSet == "linkingObjects") { + this.realm.write(() => { + setValue = this.realm.create(PersonSchema.name, { + name: "Different Joe", + age: 81, + }); + }); + } else { + setValue = valueToSet; + } + }); + + it("can add a new property", function (this: Mocha.Context & RealmContext) { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + + this.realm.write(() => { + const joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(joe).not.null; + joe.customProperty = setValue; + }); + const joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(joe).not.null; + expect(joe.name).equals("Joe"); + expect(joe.customProperty).deep.equals(setValue); + }); + + it("can add a new property", function (this: Mocha.Context & RealmContext) { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + let joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(() => joe.customProperty).throws("Property 'Person.customProperty' does not exist"); + + this.realm.write(() => { + joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(joe).not.null; + joe.customProperty = setValue; + }); + + joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(joe).not.null; + expect(joe.name).equals("Joe"); + + expect(joe.customProperty).deep.equals(setValue); + }); + + it("can delete a property", function () { + let joe: any; + this.realm.write(() => { + joe = this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + this.realm.write(() => { + joe.customProperty = setValue; + }); + expect(() => joe.customProperty).does.not.throw(); + + this.realm.write(() => { + delete joe.customProperty; + }); + joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe")!; + expect(() => joe.customProperty).throws("Property 'Person.customProperty' does not exist"); + }); + }); + }); + + it("Object.keys(), Object.values(), and Object.entries()", function () { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + + let joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + let keys = Object.keys(joe); + expect(keys.length).equal(3); // 3 (from schema) + 0 (additional property) + expect(keys).to.have.deep.members(["age", "friends", "name"]); + expect(Object.entries(joe).length).equal(3); + expect(Object.values(joe).length).equal(3); + + this.realm.write(() => { + const joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + joe.nickname = "Johannes"; + }); + + joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + keys = Object.keys(joe); + expect(keys.length).equal(4); // 3 (from schema) + 1 (additional property) + expect(keys).to.have.deep.members(["age", "friends", "name", "nickname"]); + expect(Object.entries(joe).length).equal(4); + expect(Object.values(joe).length).equal(4); + }); + + it("spread operator", function () { + this.realm.write(() => { + this.realm.create(PersonSchema.name, { + name: "Joe", + age: 19, + }); + }); + + const joe1 = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + const plainJoe1 = {...joe1}; + expect(plainJoe1 instanceof Object).to.be.true; + expect(Object.keys(plainJoe1)).to.have.deep.members(["age", "friends", "name"]); + + this.realm.write(() => { + const joe = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + joe.nickname = "Johannes"; + }); + + const joe2 = this.realm.objectForPrimaryKey(PersonSchema.name, "Joe"); + const plainJoe2 = {...joe2}; + expect(Object.keys(plainJoe2)).to.have.deep.members(["age", "friends", "name", "nickname"]); + }); +}); diff --git a/packages/realm/bindgen/js_opt_in_spec.yml b/packages/realm/bindgen/js_opt_in_spec.yml index ef80c1274e..fd810d4078 100644 --- a/packages/realm/bindgen/js_opt_in_spec.yml +++ b/packages/realm/bindgen/js_opt_in_spec.yml @@ -44,6 +44,7 @@ records: - schema - schema_version - schema_mode + - flexible_schema - disable_format_upgrade - sync_config - force_sync_history @@ -298,6 +299,7 @@ classes: - get_link_target - clear - get_primary_key_column + - get_column_key Obj: methods: @@ -305,13 +307,18 @@ classes: - get_table - get_key - get_any + - get_any_by_name - set_any + - set_any_by_name - set_collection - add_int - get_linked_object - get_backlink_count - get_backlink_view - create_and_set_linked_object + - has_schema_property + - erase_additional_prop + - get_additional_properties Timestamp: methods: diff --git a/packages/realm/bindgen/vendor/realm-core b/packages/realm/bindgen/vendor/realm-core index 6bebc40a03..9e422bda40 160000 --- a/packages/realm/bindgen/vendor/realm-core +++ b/packages/realm/bindgen/vendor/realm-core @@ -1 +1 @@ -Subproject commit 6bebc40a03ca4144050bc672a6cd86c2286caa32 +Subproject commit 9e422bda404ebd719f46ab759c2879c16c68b611 diff --git a/packages/realm/src/ClassMap.ts b/packages/realm/src/ClassMap.ts index 3153561167..a617201323 100644 --- a/packages/realm/src/ClassMap.ts +++ b/packages/realm/src/ClassMap.ts @@ -130,7 +130,7 @@ export class ClassMap { properties, wrapObject(obj) { if (obj.isValid) { - return RealmObject.createWrapper(obj, constructor); + return RealmObject.createWrapper(obj, constructor, realm.internal.config.flexibleSchema); } else { return null; } diff --git a/packages/realm/src/Configuration.ts b/packages/realm/src/Configuration.ts index 55bc7c0fa2..0d3902cee9 100644 --- a/packages/realm/src/Configuration.ts +++ b/packages/realm/src/Configuration.ts @@ -101,6 +101,12 @@ export type BaseConfiguration = { * @since 2.23.0 */ fifoFilesFallbackPath?: string; + /** + * Opening a Realm with relaxed schema means that you can dynamically add, update, and remove properties + * at runtime. These additional properties will always have the type Mixed. + * @default false + */ + relaxedSchema?: boolean; sync?: SyncConfiguration; /** @internal */ openSyncedRealmLocally?: true; @@ -184,6 +190,7 @@ export function validateConfiguration(config: unknown): asserts config is Config path, schema, schemaVersion, + relaxedSchema, inMemory, readOnly, fifoFilesFallbackPath, @@ -211,6 +218,9 @@ export function validateConfiguration(config: unknown): asserts config is Config "'schemaVersion' on realm configuration must be 0 or a positive integer.", ); } + if (relaxedSchema !== undefined) { + assert.boolean(relaxedSchema, "'relaxedSchema' on realm configuration"); + } if (inMemory !== undefined) { assert.boolean(inMemory, "'inMemory' on realm configuration"); } diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index 4e96620f0e..f96901da4b 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -42,6 +42,12 @@ import { createResultsAccessor, flags, getTypeName, + mixedToBinding, + getTypeHelpers, + getClassHelpers, + TypeOptions, + MappableTypeHelpers, + fromBindingSyncError, } from "./internal"; /** @@ -111,6 +117,66 @@ const PROXY_HANDLER: ProxyHandler> = { }, }; +const PROXY_HANDLER_RELAXED: ProxyHandler> = { + get(target, prop) { + // TODO: add type helper here too + return target[INTERNAL].getAnyByName(prop as string); + }, + + set(target, prop, value) { + const obj = target[INTERNAL]; + const propName = prop as string; + + if (obj.hasSchemaProperty(propName)) { + const colKey = obj.table.getColumnKey(propName); + const options: TypeOptions = { + realm: target[REALM], + name: propName, + optional: false, + objectSchemaName: obj.table.name, + objectType: undefined, + getClassHelpers: function (nameOrTableKey: string | binding.TableKey): ClassHelpers { + throw new Error("Function not implemented."); + } + }; + const typ = obj.table.getColumnType(colKey); + const typeHelper = getTypeHelpers(typ as unknown as MappableTypeHelpers, options); + obj.setAny(colKey, typeHelper.toBinding(value)); + } else { + obj.setAnyByName(propName, value); + } + return true; + }, + + deleteProperty(target, prop) { + const obj = target[INTERNAL]; + const propName = prop as string; + + if (obj.hasSchemaProperty(propName)) { + // TODO: Discuss + throw new Error("Unsupported"); + } else { + obj.eraseAdditionalProp(propName); + } + return true; + }, + + ownKeys(target) { + const obj = target[INTERNAL]; + const schema = (target as Realm.Object).objectSchema(); + let keys = Object.keys(schema.properties); + let additionalProperties = obj.getAdditionalProperties(); + return [...keys, ...additionalProperties]; + }, + + getOwnPropertyDescriptor(_) { + return { + enumerable: true, + configurable: true, + }; + }, +}; + /** * Base class for a Realm Object. * @example @@ -308,7 +374,11 @@ export class RealmObject(internal: binding.Obj, constructor: Constructor): RealmObject & T { + public static createWrapper( + internal: binding.Obj, + constructor: Constructor, + relaxedSchema: boolean, + ): RealmObject & T { const result = Object.create(constructor.prototype); result[INTERNAL] = internal; // Initializing INTERNAL_LISTENERS here rather than letting it just be implicitly undefined since JS engines @@ -316,7 +386,11 @@ export class RealmObject {