From 5ca8683e4e6b76747f4b7910c1957b91fd57cb85 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 13 May 2026 08:32:48 -0700 Subject: [PATCH 1/4] Add Failing Tests For Self Referencing When a model references itself updating one side updates the other in unexpected ways. --- tests/relations/many-to-many.test.ts | 49 ++++++++++++++++++++++++++++ tests/relations/many-to-one.test.ts | 34 +++++++++++++++++++ tests/relations/one-to-many.test.ts | 33 +++++++++++++++++++ tests/relations/one-to-one.test.ts | 34 +++++++++++++++++++ 4 files changed, 150 insertions(+) diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index a36f714..e8289da 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -122,3 +122,52 @@ it('scopes a nested many-to-many relation update to the targeted record', async expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) }) + +it('updates a self referencing many to many relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parents() { + return z.array(userSchema).optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ many }) => ({ + children: many(users, { role: 'children' }), + parents: many(users, { role: 'children' }), + })) + + const child = await users.create({ + id: 1, + }) + + const parent = await users.create({ + id: 2, + children: [child], + }) + + expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(child.children).toEqual([]) + + expect.soft(child.parents).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parent.parents).toEqual([]) + + const parent2 = await users.create({ + id: 3, + }) + + const child2 = await users.create({ + id: 4, + parents: [child], + }) + + expect.soft(parent2.children).toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(child2.children).toEqual([]) + + expect.soft(child2.parents).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parent2.parents).toEqual([]) +}) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index 4f5a08f..99ac4e2 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -127,3 +127,37 @@ it("scopes nested updates to the updated owner's foreign record", async () => { expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) }) + +it('updates a self referencing many to one relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one, many }) => ({ + children: many(users, { role: 'children' }), + parent: one(users, { role: 'children' }), + })) + + const parent = await users.create({ + id: 2, + }) + + const child = await users.create({ + id: 1, + parent, + }) + + expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(child.children).toEqual([]) + + expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parent.parent).toBeUndefined() +}) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index 4af7769..3f750cd 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -539,3 +539,36 @@ it('scopes a nested one-to-many relation update to the targeted record', async ( expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) }) + +it('updates a self referencing one to many relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one, many }) => ({ + children: many(users, { role: 'children' }), + parent: one(users, { role: 'children' }), + })) + + const child = await users.create({ + id: 1, + }) + const parent = await users.create({ + id: 2, + children: [child], + }) + + expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(child.children).toEqual([]) + + expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parent.parent).toBeUndefined() +}) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 7f8c550..05e0232 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -651,3 +651,37 @@ it('removes relation listeners when the owner record is deleted', async () => { 'Detaches relation listeners when the owner record is deleted', ).toBe(baseline) }) + +it('updates a self referencing one to one relation', async () => { + const userSchema = z.object({ + id: z.number(), + get child() { + return userSchema.optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + child: one(users, { role: 'children' }), + parent: one(users, { role: 'children' }), + })) + + const parent = await users.create({ + id: 2, + }) + + const child = await users.create({ + id: 1, + parent, + }) + + expect.soft(parent.child).toEqual(expect.objectContaining({ id: 1 })) + expect.soft(child.child).toBeUndefined() + + expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parent.parent).toBeUndefined() +}) From 2d46d17202c9a12e497f78866959554c804cf861 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 15 May 2026 22:05:49 +0200 Subject: [PATCH 2/4] fix: skip self-referencing relations --- src/relation.ts | 21 ++++++++- tests/relations/many-to-many.test.ts | 66 ++++++++++++++-------------- tests/relations/many-to-one.test.ts | 10 ++--- tests/relations/one-to-many.test.ts | 10 ++--- tests/relations/one-to-one.test.ts | 10 ++--- 5 files changed, 69 insertions(+), 48 deletions(-) diff --git a/src/relation.ts b/src/relation.ts index 6eca9d4..e800ce0 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -617,8 +617,27 @@ export abstract class Relation { */ public getRelationsToOwner(foreignRecord: RecordType): Array { const result: Array = [] + const isSelfReferencing = this.foreignCollections.some( + (foreignCollection) => { + return ( + foreignCollection[kCollectionId] === + this.ownerCollection[kCollectionId] + ) + }, + ) + const ownPath = this.path.join('.') + + for (const [serializedPath, relation] of foreignRecord[kRelationMap]) { + /** + * @note For self-referencing relations, the relation at the same path + * on the foreign record is not the inverse — it's the same logical + * relation pointing in the same direction. Skip it so we only return + * the actual inverse relation (a different path with the same role). + */ + if (isSelfReferencing && serializedPath === ownPath) { + continue + } - for (const [, relation] of foreignRecord[kRelationMap]) { if ( relation.foreignCollections.some((foreignCollection) => { return ( diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index e8289da..b4412b5 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -123,7 +123,7 @@ it('scopes a nested many-to-many relation update to the targeted record', async expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) }) -it('updates a self referencing many to many relation', async () => { +it('creates a self referencing many-to-many relation', async () => { const userSchema = z.object({ id: z.number(), get children() { @@ -137,37 +137,39 @@ it('updates a self referencing many to many relation', async () => { const users = new Collection({ schema: userSchema }) users.defineRelations(({ many }) => ({ - children: many(users, { role: 'children' }), - parents: many(users, { role: 'children' }), + children: many(users, { role: 'hierarchy' }), + parents: many(users, { role: 'hierarchy' }), })) - const child = await users.create({ - id: 1, - }) - - const parent = await users.create({ - id: 2, - children: [child], - }) - - expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) - expect.soft(child.children).toEqual([]) - - expect.soft(child.parents).toEqual(expect.objectContaining({ id: 2 })) - expect.soft(parent.parents).toEqual([]) - - const parent2 = await users.create({ - id: 3, - }) - - const child2 = await users.create({ - id: 4, - parents: [child], - }) - - expect.soft(parent2.children).toEqual([expect.objectContaining({ id: 1 })]) - expect.soft(child2.children).toEqual([]) - - expect.soft(child2.parents).toEqual(expect.objectContaining({ id: 2 })) - expect.soft(parent2.parents).toEqual([]) + { + const childOne = await users.create({ + id: 1, + }) + + const parentOne = await users.create({ + id: 2, + children: [childOne], + }) + + expect + .soft(parentOne.children) + .toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(parentOne.parents).toEqual([]) + + expect.soft(childOne.children).toEqual([]) + expect.soft(childOne.parents).toEqual([expect.objectContaining({ id: 2 })]) + } + + { + const parentTwo = await users.create({ id: 3 }) + const childTwo = await users.create({ id: 4, parents: [parentTwo] }) + + expect + .soft(parentTwo.children) + .toEqual([expect.objectContaining({ id: 4 })]) + expect.soft(parentTwo.parents).toEqual([]) + + expect.soft(childTwo.children).toEqual([]) + expect.soft(childTwo.parents).toEqual([expect.objectContaining({ id: 3 })]) + } }) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index 99ac4e2..c7762c1 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -128,7 +128,7 @@ it("scopes nested updates to the updated owner's foreign record", async () => { expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) }) -it('updates a self referencing many to one relation', async () => { +it('creates a self referencing many-to-one relation', async () => { const userSchema = z.object({ id: z.number(), get children() { @@ -142,8 +142,8 @@ it('updates a self referencing many to one relation', async () => { const users = new Collection({ schema: userSchema }) users.defineRelations(({ one, many }) => ({ - children: many(users, { role: 'children' }), - parent: one(users, { role: 'children' }), + children: many(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), })) const parent = await users.create({ @@ -156,8 +156,8 @@ it('updates a self referencing many to one relation', async () => { }) expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) - expect.soft(child.children).toEqual([]) + expect.soft(parent.parent).toBeUndefined() + expect.soft(child.children).toEqual([]) expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) - expect.soft(parent.parent).toBeUndefined() }) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index 3f750cd..0238bcf 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -540,7 +540,7 @@ it('scopes a nested one-to-many relation update to the targeted record', async ( expect(posts.all().map((post) => post.title)).toEqual(['Updated', 'Second']) }) -it('updates a self referencing one to many relation', async () => { +it('creates a self referencing one-to-many relation', async () => { const userSchema = z.object({ id: z.number(), get children() { @@ -554,8 +554,8 @@ it('updates a self referencing one to many relation', async () => { const users = new Collection({ schema: userSchema }) users.defineRelations(({ one, many }) => ({ - children: many(users, { role: 'children' }), - parent: one(users, { role: 'children' }), + children: many(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), })) const child = await users.create({ @@ -567,8 +567,8 @@ it('updates a self referencing one to many relation', async () => { }) expect.soft(parent.children).toEqual([expect.objectContaining({ id: 1 })]) - expect.soft(child.children).toEqual([]) + expect.soft(parent.parent).toBeUndefined() + expect.soft(child.children).toEqual([]) expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) - expect.soft(parent.parent).toBeUndefined() }) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 05e0232..87ac35b 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -652,7 +652,7 @@ it('removes relation listeners when the owner record is deleted', async () => { ).toBe(baseline) }) -it('updates a self referencing one to one relation', async () => { +it('creates a self referencing one-to-one relation', async () => { const userSchema = z.object({ id: z.number(), get child() { @@ -666,8 +666,8 @@ it('updates a self referencing one to one relation', async () => { const users = new Collection({ schema: userSchema }) users.defineRelations(({ one }) => ({ - child: one(users, { role: 'children' }), - parent: one(users, { role: 'children' }), + child: one(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), })) const parent = await users.create({ @@ -680,8 +680,8 @@ it('updates a self referencing one to one relation', async () => { }) expect.soft(parent.child).toEqual(expect.objectContaining({ id: 1 })) - expect.soft(child.child).toBeUndefined() + expect.soft(parent.parent).toBeUndefined() + expect.soft(child.child).toBeUndefined() expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) - expect.soft(parent.parent).toBeUndefined() }) From 863b13dd6c67a5c848dfbaf0673883a6451eb126 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 15 May 2026 22:07:16 +0200 Subject: [PATCH 3/4] test: add self-references update tests --- tests/relations/many-to-many.test.ts | 34 ++++++++++++++++++++++++++++ tests/relations/many-to-one.test.ts | 33 +++++++++++++++++++++++++++ tests/relations/one-to-many.test.ts | 33 +++++++++++++++++++++++++++ tests/relations/one-to-one.test.ts | 33 +++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index b4412b5..e0f4f12 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -173,3 +173,37 @@ it('creates a self referencing many-to-many relation', async () => { expect.soft(childTwo.parents).toEqual([expect.objectContaining({ id: 3 })]) } }) + +it('updates a self referencing many-to-many relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parents() { + return z.array(userSchema).optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ many }) => ({ + children: many(users, { role: 'hierarchy' }), + parents: many(users, { role: 'hierarchy' }), + })) + + const childOne = await users.create({ id: 1 }) + const childTwo = await users.create({ id: 2 }) + const parent = await users.create({ id: 3, children: [childOne] }) + + await users.update(parent, { + data(draft) { + draft.children = [childTwo] + }, + }) + + expect.soft(parent.children).toEqual([expect.objectContaining({ id: 2 })]) + expect.soft(parent.parents).toEqual([]) + expect.soft(childOne.parents).toEqual([]) + expect.soft(childTwo.parents).toEqual([expect.objectContaining({ id: 3 })]) +}) diff --git a/tests/relations/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index c7762c1..1bdc120 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -161,3 +161,36 @@ it('creates a self referencing many-to-one relation', async () => { expect.soft(child.children).toEqual([]) expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) }) + +it('updates a self referencing many-to-one relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one, many }) => ({ + children: many(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), + })) + + const parentOne = await users.create({ id: 1 }) + const parentTwo = await users.create({ id: 2 }) + const child = await users.create({ id: 3, parent: parentOne }) + + await users.update(child, { + data(draft) { + draft.parent = parentTwo + }, + }) + + expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parentOne.children).toEqual([]) + expect.soft(parentTwo.children).toEqual([expect.objectContaining({ id: 3 })]) +}) diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index 0238bcf..a8b16b0 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -572,3 +572,36 @@ it('creates a self referencing one-to-many relation', async () => { expect.soft(child.children).toEqual([]) expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) }) + +it('updates a self referencing one-to-many relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one, many }) => ({ + children: many(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), + })) + + const childOne = await users.create({ id: 1 }) + const childTwo = await users.create({ id: 2 }) + const parent = await users.create({ id: 3, children: [childOne] }) + + await users.update(parent, { + data(draft) { + draft.children = [childTwo] + }, + }) + + expect.soft(parent.children).toEqual([expect.objectContaining({ id: 2 })]) + expect.soft(childOne.parent).toBeUndefined() + expect.soft(childTwo.parent).toEqual(expect.objectContaining({ id: 3 })) +}) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index 87ac35b..ad24408 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -685,3 +685,36 @@ it('creates a self referencing one-to-one relation', async () => { expect.soft(child.child).toBeUndefined() expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) }) + +it('updates a self referencing one-to-one relation', async () => { + const userSchema = z.object({ + id: z.number(), + get child() { + return userSchema.optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + child: one(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), + })) + + const parentOne = await users.create({ id: 1 }) + const parentTwo = await users.create({ id: 2 }) + const child = await users.create({ id: 3, parent: parentOne }) + + await users.update(child, { + data(draft) { + draft.parent = parentTwo + }, + }) + + expect.soft(child.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(parentOne.child).toBeUndefined() + expect.soft(parentTwo.child).toEqual(expect.objectContaining({ id: 3 })) +}) From c0d089fb50ea1ceeac85d6dc05e89c69fdf0bde6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 15 May 2026 22:09:42 +0200 Subject: [PATCH 4/4] test: add circular self-references test cases --- tests/relations/many-to-many.test.ts | 33 ++++++++++++++++++++++++++++ tests/relations/one-to-one.test.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/relations/many-to-many.test.ts b/tests/relations/many-to-many.test.ts index e0f4f12..4e1e300 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -207,3 +207,36 @@ it('updates a self referencing many-to-many relation', async () => { expect.soft(childOne.parents).toEqual([]) expect.soft(childTwo.parents).toEqual([expect.objectContaining({ id: 3 })]) }) + +it('resolves a cyclic self referencing many-to-many relation', async () => { + const userSchema = z.object({ + id: z.number(), + get children() { + return z.array(userSchema).optional() + }, + get parents() { + return z.array(userSchema).optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ many }) => ({ + children: many(users, { role: 'hierarchy' }), + parents: many(users, { role: 'hierarchy' }), + })) + + const alice = await users.create({ id: 1 }) + const bob = await users.create({ id: 2, parents: [alice] }) + + await users.update(alice, { + data(draft) { + draft.parents = [bob] + }, + }) + + expect.soft(alice.parents).toEqual([expect.objectContaining({ id: 2 })]) + expect.soft(alice.children).toEqual([expect.objectContaining({ id: 2 })]) + expect.soft(bob.parents).toEqual([expect.objectContaining({ id: 1 })]) + expect.soft(bob.children).toEqual([expect.objectContaining({ id: 1 })]) +}) diff --git a/tests/relations/one-to-one.test.ts b/tests/relations/one-to-one.test.ts index ad24408..127cefe 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -718,3 +718,35 @@ it('updates a self referencing one-to-one relation', async () => { expect.soft(parentOne.child).toBeUndefined() expect.soft(parentTwo.child).toEqual(expect.objectContaining({ id: 3 })) }) + +it('resolves a cyclic self referencing one-to-one relation', async () => { + const userSchema = z.object({ + id: z.number(), + get child() { + return userSchema.optional() + }, + get parent() { + return userSchema.optional() + }, + }) + + const users = new Collection({ schema: userSchema }) + + users.defineRelations(({ one }) => ({ + child: one(users, { role: 'hierarchy' }), + parent: one(users, { role: 'hierarchy' }), + })) + + const alice = await users.create({ id: 1 }) + const bob = await users.create({ id: 2, parent: alice }) + + await users.update(alice, { + data(draft) { + draft.parent = bob + }, + }) + + expect.soft(alice.parent).toEqual(expect.objectContaining({ id: 2 })) + expect.soft(bob.parent).toEqual(expect.objectContaining({ id: 1 })) + expect.soft(alice.parent?.parent).toEqual(expect.objectContaining({ id: 1 })) +})