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 a36f714..4e1e300 100644 --- a/tests/relations/many-to-many.test.ts +++ b/tests/relations/many-to-many.test.ts @@ -122,3 +122,121 @@ 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('creates 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 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 })]) + } +}) + +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 })]) +}) + +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/many-to-one.test.ts b/tests/relations/many-to-one.test.ts index 4f5a08f..1bdc120 100644 --- a/tests/relations/many-to-one.test.ts +++ b/tests/relations/many-to-one.test.ts @@ -127,3 +127,70 @@ it("scopes nested updates to the updated owner's foreign record", async () => { expect(countries.all()).toEqual([{ code: 'uk' }, { code: 'ca' }]) }) + +it('creates 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 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(parent.parent).toBeUndefined() + + 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 4af7769..a8b16b0 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -539,3 +539,69 @@ 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('creates 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 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(parent.parent).toBeUndefined() + + 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 7f8c550..127cefe 100644 --- a/tests/relations/one-to-one.test.ts +++ b/tests/relations/one-to-one.test.ts @@ -651,3 +651,102 @@ it('removes relation listeners when the owner record is deleted', async () => { 'Detaches relation listeners when the owner record is deleted', ).toBe(baseline) }) + +it('creates 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 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(parent.parent).toBeUndefined() + + 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 })) +}) + +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 })) +})