diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be6c00a93..cc0fe773a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ * None ### Fixed -* ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) -* None +* Trying to create an invalid object (e.g. missing required properties) via `realm.create()` or a `Realm.Object` constructor would both throw and create the object (with default values for required properties), causing the object creation to not be rolled back in the following cases: + * A) During a manual transaction if the user does not explicitly catch the exception and cancel the transaction. + * B) If the user catches the exception within the callback passed to `realm.write()` and does not rethrow it. + * Fix: The operation is now automatically rolled back. ([#2638](https://github.com/realm/realm-js/issues/2638)) ### Compatibility * React Native >= v0.71.4 @@ -23,7 +25,7 @@ ## 12.5.1 (2024-01-03) ### Fixed -* Accessing the `providerType` on a `UserIdentity` via `User.identities` always yielded `undefined`. Thanks to [@joelowry96](https://github.com/joelowry96) for pinpointing the fix. +* Accessing the `providerType` on a `UserIdentity` via `User.identities` always yielded `undefined`. Thanks to [@joelowry96](https://github.com/joelowry96) for pinpointing the fix. ([#6248](https://github.com/realm/realm-js/issues/6248)) * Bad performance of initial Sync download involving many backlinks. ([realm/realm-core#7217](https://github.com/realm/realm-core/issues/7217), since v10.0.0) ### Compatibility diff --git a/integration-tests/tests/src/schemas/person-and-dogs.ts b/integration-tests/tests/src/schemas/person-and-dogs.ts index 8419bf2d6b..aa461b7edd 100644 --- a/integration-tests/tests/src/schemas/person-and-dogs.ts +++ b/integration-tests/tests/src/schemas/person-and-dogs.ts @@ -76,3 +76,31 @@ export class Dog extends Realm.Object { static schema: Realm.ObjectSchema = DogSchema; } + +export interface IPersonWithEmbedded { + name: string; + age: number; + address?: IEmbeddedAddress; +} + +export const PersonWithEmbeddedSchema: Realm.ObjectSchema = { + name: "PersonWithEmbedded", + primaryKey: "name", + properties: { + age: "int", + name: "string", + address: "EmbeddedAddress?", + }, +}; + +export interface IEmbeddedAddress { + street: string; +} + +export const EmbeddedAddressSchema: Realm.ObjectSchema = { + name: "EmbeddedAddress", + embedded: true, + properties: { + street: "string", + }, +}; diff --git a/integration-tests/tests/src/tests/transaction.ts b/integration-tests/tests/src/tests/transaction.ts index b2f19f28e0..af26804b90 100644 --- a/integration-tests/tests/src/tests/transaction.ts +++ b/integration-tests/tests/src/tests/transaction.ts @@ -16,12 +16,18 @@ // //////////////////////////////////////////////////////////////////////////// import { expect } from "chai"; -import { openRealmBeforeEach } from "../hooks"; +import { UpdateMode } from "realm"; -import { PersonSchema } from "../schemas/person-and-dogs"; +import { openRealmBeforeEach } from "../hooks"; +import { + EmbeddedAddressSchema, + IPersonWithEmbedded, + PersonSchema, + PersonWithEmbeddedSchema, +} from "../schemas/person-and-dogs"; describe("Realm transactions", () => { - openRealmBeforeEach({ schema: [PersonSchema] }); + openRealmBeforeEach({ schema: [PersonSchema, PersonWithEmbeddedSchema, EmbeddedAddressSchema] }); describe("Manual transactions", () => { it("can perform a manual transaction", function (this: RealmContext) { @@ -69,8 +75,7 @@ describe("Realm transactions", () => { realm.commitTransaction(); // We don't expect this to be called }).throws("Expected value to be a number or bigint, got a string"); - // TODO: Fix 👇 ... its a bit surprising that an object gets created at all - expect(persons.length).equals(1); + expect(persons.length).equals(0); expect(realm.isInTransaction).to.be.true; realm.cancelTransaction(); @@ -85,4 +90,125 @@ describe("Realm transactions", () => { expect(!this.realm.isInTransaction).to.be.true; }); }); + + describe("Transactions via realm.write()", () => { + it("`realm.create()` does not create an object if it throws", function (this: Mocha.Context & RealmContext) { + this.realm.write(() => { + // It is important to catch the exception within `realm.write()` in order to test + // that the object creation path does not create the object (rather than being due + // to `realm.write()` cancelling the transaction). + expect(() => { + const invalidPerson = { name: "Amy" }; + this.realm.create(PersonWithEmbeddedSchema.name, invalidPerson); + }).to.throw("Missing value for property 'age'"); + }); + expect(this.realm.objects(PersonWithEmbeddedSchema.name).length).equals(0); + }); + + it("`realm.create()` does not create an object if having an invalid embedded object", function (this: Mocha.Context & + RealmContext) { + this.realm.write(() => { + expect(() => { + const invalidEmbeddedAddress = {}; + this.realm.create(PersonWithEmbeddedSchema.name, { + name: "Amy", + age: 30, + address: invalidEmbeddedAddress, + }); + }).to.throw("Missing value for property 'street'"); + }); + expect(this.realm.objects(PersonWithEmbeddedSchema.name).length).equals(0); + }); + + it("commits successful operations if exceptions from failed ones are caught", function (this: Mocha.Context & + RealmContext) { + this.realm.write(() => { + this.realm.create(PersonWithEmbeddedSchema.name, { name: "John", age: 30 }); + expect(() => { + const invalidPerson = { name: "Amy" }; + this.realm.create(PersonWithEmbeddedSchema.name, invalidPerson); + }).to.throw("Missing value for property 'age'"); + }); + const objects = this.realm.objects(PersonWithEmbeddedSchema.name); + expect(objects.length).equals(1); + expect(objects[0].name).equals("John"); + }); + + it("does not commit the transaction if any operation that throws is not caught", function (this: Mocha.Context & + RealmContext) { + expect(() => { + this.realm.write(() => { + this.realm.create(PersonWithEmbeddedSchema.name, { name: "John", age: 30 }); + // Don't catch any exceptions within `realm.write()`. + const invalidPerson = { name: "Amy" }; + this.realm.create(PersonWithEmbeddedSchema.name, invalidPerson); + }); + }).to.throw("Missing value for property 'age'"); + expect(this.realm.objects(PersonWithEmbeddedSchema.name).length).equals(0); + }); + + // TODO: Enable when fixing this issue: https://github.com/realm/realm-js/issues/6355 + it.skip("does not modify an embedded object if resetting it to an invalid one via a setter", function (this: Mocha.Context & + RealmContext) { + const amy = this.realm.write(() => { + return this.realm.create(PersonWithEmbeddedSchema.name, { + name: "Amy", + age: 30, + address: { street: "Broadway" }, + }); + }); + expect(this.realm.objects(PersonWithEmbeddedSchema.name).length).equals(1); + expect(amy.address?.street).equals("Broadway"); + + this.realm.write(() => { + // It is important to catch the exception within `realm.write()` in order to test + // that the object creation path does not modify the object (rather than being due + // to `realm.write()` cancelling the transaction). + expect(() => { + const invalidEmbeddedAddress = {}; + // @ts-expect-error Testing setting invalid type. + amy.address = invalidEmbeddedAddress; + }).to.throw("Missing value for property 'street'"); + }); + const objects = this.realm.objects(PersonWithEmbeddedSchema.name); + expect(objects.length).equals(1); + expect(objects[0].address).to.not.be.null; + expect(objects[0].address?.street).equals("Broadway"); + }); + + // TODO: Enable when fixing this issue: https://github.com/realm/realm-js/issues/6355 + it.skip("does not modify an embedded object if resetting it to an invalid one via UpdateMode", function (this: Mocha.Context & + RealmContext) { + const amy = this.realm.write(() => { + return this.realm.create(PersonWithEmbeddedSchema.name, { + name: "Amy", + age: 30, + address: { street: "Broadway" }, + }); + }); + expect(this.realm.objects(PersonWithEmbeddedSchema.name).length).equals(1); + expect(amy.address?.street).equals("Broadway"); + + this.realm.write(() => { + // It is important to catch the exception within `realm.write()` in order to test + // that the object creation path does not modify the object (rather than being due + // to `realm.write()` cancelling the transaction). + expect(() => { + const invalidEmbeddedAddress = {}; + this.realm.create( + PersonWithEmbeddedSchema.name, + { + name: "Amy", + address: invalidEmbeddedAddress, + }, + UpdateMode.Modified, + ); + }).to.throw("Missing value for property 'street'"); + }); + const objects = this.realm.objects(PersonWithEmbeddedSchema.name); + expect(objects.length).equals(1); + expect(objects[0].address).to.not.be.null; + expect(objects[0].address?.street).equals("Broadway"); + }); + }); }); diff --git a/packages/realm/src/Object.ts b/packages/realm/src/Object.ts index 958342b100..e419e6e020 100644 --- a/packages/realm/src/Object.ts +++ b/packages/realm/src/Object.ts @@ -66,7 +66,7 @@ export enum UpdateMode { } /** @internal */ -export type ObjCreator = () => [binding.Obj, boolean]; +export type ObjCreator = () => [binding.Obj, boolean, binding.TableRef?]; type CreationContext = { helpers: ClassHelpers; @@ -213,27 +213,27 @@ export class RealmObject