diff --git a/src/relation.ts b/src/relation.ts index 01bad50..7bcf337 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -199,6 +199,14 @@ export abstract class Relation { path.every((key, index) => key === update.path[index]) && !isRecord(update.nextValue) ) { + /** + * @note Listeners are attached per-record but fire for every owner update. + * Skip events whose target record's relation isn't this instance. + */ + if (update.prevRecord[kRelationMap].get(serializedPath) !== this) { + return + } + event.preventDefault() event.stopImmediatePropagation() @@ -231,6 +239,14 @@ export abstract class Relation { const update = event.data if (isEqual(update.path, path) && isRecord(update.nextValue)) { + /** + * @note Listeners are attached per-record but fire for every owner update. + * Skip events whose target record's relation isn't this instance. + */ + if (update.prevRecord[kRelationMap].get(serializedPath) !== this) { + return + } + event.preventDefault() // If the owner relation is "one-of", multiple foreign records cannot own this record. @@ -269,18 +285,20 @@ export abstract class Relation { // Check any other owners associated with the same foreign record. // This is important since unique relations are not always two-way. - const otherOwnersAssociatedWithForeignRecord = - this.#getOtherOwnerForRecords([update.nextValue]) - - invariant.as( - RelationError.for( - RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, - this.#createErrorDetails(), - ), - otherOwnersAssociatedWithForeignRecord == null, - 'Failed to update a unique relation at "%s": the foreign record is already associated with another owner', - update.path.join('.'), - ) + if (this.options.unique) { + const otherOwnersAssociatedWithForeignRecord = + this.#getOtherOwnerForRecords([update.nextValue]) + + invariant.as( + RelationError.for( + RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE, + this.#createErrorDetails(), + ), + otherOwnersAssociatedWithForeignRecord == null, + 'Failed to update a unique relation at "%s": the foreign record is already associated with another owner', + update.path.join('.'), + ) + } this.foreignKeys.clear() } diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index b91a6f7..a36f714 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -96,3 +96,29 @@ it('respects updates of foreign records', async () => { expect(firstPost.authors).toEqual([firstUser, updatedSecondUser]) expect(secondPost.authors).toEqual([firstUser, updatedSecondUser]) }) + +it('scopes a nested many-to-many relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ many }) => ({ + posts: many(posts), + })) + posts.defineRelations(({ many }) => ({ + authors: many(users), + })) + + const firstPost = await posts.create({ title: 'First' }) + const secondPost = await posts.create({ title: 'Second' }) + + const firstUser = await users.create({ id: 1, posts: [firstPost] }) + await users.create({ id: 2, posts: [secondPost] }) + + await users.update(firstUser, { + data(user) { + user.posts[0]!.title = 'Updated' + }, + }) + + expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) +}) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index 9c92194..4f5a08f 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -35,3 +35,95 @@ it('supports many-to-one relations', async () => { .soft(posts.findFirst((q) => q.where({ title: 'Second' }))) .toEqual({ title: 'Second', author: { id: 1 } }) }) + +it('scopes a many-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + posts.defineRelations(({ one }) => ({ + author: one(users), + })) + + const userOne = await users.create({ id: 1 }) + const userTwo = await users.create({ id: 2 }) + + const firstPost = await posts.create({ title: 'First', author: userOne }) + await posts.create({ title: 'Second', author: userTwo }) + + await posts.update(firstPost, { + data(post) { + post.author = userTwo + }, + }) + await posts.update(firstPost, { + data(post) { + post.author = userOne + }, + }) + + expect(posts.all()).toEqual([ + { title: 'First', author: { id: 1 } }, + { title: 'Second', author: { id: 2 } }, + ]) +}) + +it('updates a many-to-one relation to a foreign record already associated with another owner', async () => { + const languageSchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + language: languageSchema, + }) + + const languages = new Collection({ schema: languageSchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + language: one(languages, { unique: false }), + })) + + const langPt = await languages.create({ code: 'pt' }) + const langEn = await languages.create({ code: 'en' }) + + const userOne = await users.create({ name: 'John', language: langPt }) + await users.create({ name: 'Kate', language: langEn }) + + await users.update(userOne, { + data(user) { + user.language = langEn + }, + }) + + expect(users.all()).toEqual([ + { name: 'John', language: { code: 'en' } }, + { name: 'Kate', language: { code: 'en' } }, + ]) +}) + +it("scopes nested updates to the updated owner's foreign record", async () => { + const countrySchema = z.object({ code: z.string() }) + const userSchema = z.object({ + name: z.string(), + country: countrySchema, + }) + + const countries = new Collection({ schema: countrySchema }) + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + const us = await countries.create({ code: 'us' }) + const ca = await countries.create({ code: 'ca' }) + + const userOne = await users.create({ name: 'John', country: us }) + await users.create({ name: 'Kate', country: ca }) + + await users.update(userOne, { + data(user) { + user.country.code = 'uk' + }, + }) + + expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) +}) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index bcc6a2b..8525bf7 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -453,3 +453,29 @@ it('errors when creating a unique relation with already associated foreign recor ) } }) + +it('scopes a nested one-to-many relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ many }) => ({ + posts: many(posts), + })) + + const firstUser = await users.create({ + id: 1, + posts: [await posts.create({ title: 'First' })], + }) + await users.create({ + id: 2, + posts: [await posts.create({ title: 'Second' })], + }) + + await users.update(firstUser, { + data(user) { + user.posts[0]!.title = 'Updated' + }, + }) + + expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) +}) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 7dc3626..2db9cd3 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -562,3 +562,58 @@ it('errors when updating a unique two-way relation referencing a taken foreign r ), ) }) + +it('scopes a one-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const countries = new Collection({ schema: countrySchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + await users.create({ + id: 1, + country: await countries.create({ code: 'us' }), + }) + await users.create({ + id: 2, + country: await countries.create({ code: 'ca' }), + }) + + await users.update((q) => q.where({ id: 1 }), { + async data(user) { + user.country = await countries.create({ code: 'uk' }) + }, + }) + + expect(users.all()).toEqual([ + { id: 1, country: { code: 'uk' } }, + { id: 2, country: { code: 'ca' } }, + ]) +}) + +it('scopes a nested one-to-one relation update to the targeted record', async () => { + const users = new Collection({ schema: userSchema }) + const countries = new Collection({ schema: countrySchema }) + + users.defineRelations(({ one }) => ({ + country: one(countries), + })) + + await users.create({ + id: 1, + country: await countries.create({ code: 'us' }), + }) + await users.create({ + id: 2, + country: await countries.create({ code: 'ca' }), + }) + + await users.update((q) => q.where({ id: 1 }), { + data(user) { + user.country.code = 'uk' + }, + }) + + expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) +})