Skip to content

Commit 0b4c441

Browse files
committed
fix: validate other owners in unique one-way relations
1 parent 2947486 commit 0b4c441

File tree

2 files changed

+101
-21
lines changed

2 files changed

+101
-21
lines changed

src/relation.ts

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -245,27 +245,42 @@ export abstract class Relation {
245245
},
246246
)
247247

248+
const foreignRelationsToDisassociate = oldForeignRecords.flatMap(
249+
(record) => this.getRelationsToOwner(record),
250+
)
251+
248252
// Throw if attempting to disassociate unique relations.
249253
if (this.options.unique) {
250254
invariant.as(
251255
RelationError.for(
252256
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
253257
this.#createErrorDetails(),
254258
),
255-
oldForeignRecords.length === 0,
259+
foreignRelationsToDisassociate.length === 0,
256260
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
257261
update.path.join('.'),
258262
)
259263
}
260264

261-
const foreignRelationsToDisassociate = oldForeignRecords.flatMap(
262-
(record) => this.getRelationsToOwner(record),
263-
)
264-
265265
for (const foreignRelation of foreignRelationsToDisassociate) {
266266
foreignRelation.foreignKeys.delete(update.prevRecord[kPrimaryKey])
267267
}
268268

269+
// Check any other owners associated with the same foreign record.
270+
// This is important since unique relations are not always two-way.
271+
const otherOwnersAssociatedWithForeignRecord =
272+
this.#getOtherOwnerForRecords([update.nextValue])
273+
274+
invariant.as(
275+
RelationError.for(
276+
RelationErrorCodes.FORBIDDEN_UNIQUE_UPDATE,
277+
this.#createErrorDetails(),
278+
),
279+
otherOwnersAssociatedWithForeignRecord == null,
280+
'Failed to update a unique relation at "%s": the foreign record is already associated with another owner',
281+
update.path.join('.'),
282+
)
283+
269284
this.foreignKeys.clear()
270285
}
271286

@@ -316,24 +331,19 @@ export abstract class Relation {
316331
initialValue,
317332
)
318333

319-
const initialForeignEntries: Array<RecordType> = Array.prototype
334+
const initialForeignRecords: Array<RecordType> = Array.prototype
320335
.concat([], get(initialValues, path))
321336
/**
322337
* @note If the initial value as an empty array, concatenating it above
323338
* results in [undefined]. Filter out undefined values.
324339
*/
325340
.filter(Boolean)
326341

327-
logger.log('all foreign entries:', initialForeignEntries)
342+
logger.log('all foreign entries:', initialForeignRecords)
328343

329-
/**
330-
* @todo Check if:
331-
* 1. Relation is unique;
332-
* 2. Foreign entries already reference something!
333-
* Then, throw.
334-
*/
335344
if (this.options.unique) {
336-
const foreignRelations = initialForeignEntries.flatMap(
345+
// Check if the foreign record isn't associated with another owner.
346+
const foreignRelations = initialForeignRecords.flatMap(
337347
(foreignRecord) => {
338348
return this.getRelationsToOwner(foreignRecord)
339349
},
@@ -343,20 +353,32 @@ export abstract class Relation {
343353
(relation) => relation.foreignKeys.size === 0,
344354
)
345355

346-
const recordLabel = this instanceof Many ? 'records' : 'record'
347-
348356
invariant.as(
349357
RelationError.for(
350358
RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE,
351359
this.#createErrorDetails(),
352360
),
353361
isUnique,
354-
`Failed to create a unique relation at "%s": foreign ${recordLabel} already associated with another owner`,
355-
this.path.join('.'),
362+
`Failed to create a unique relation at "%s": foreign ${this instanceof Many ? 'records' : 'record'} already associated with another owner`,
363+
serializedPath,
364+
)
365+
366+
// Check if another owner isn't associated with the foreign record.
367+
const otherOwnersAssociatedWithForeignRecord =
368+
this.#getOtherOwnerForRecords(initialForeignRecords)
369+
370+
invariant.as(
371+
RelationError.for(
372+
RelationErrorCodes.FORBIDDEN_UNIQUE_CREATE,
373+
this.#createErrorDetails(),
374+
),
375+
otherOwnersAssociatedWithForeignRecord == null,
376+
'Failed to create a unique relation at "%s": the foreign record is already associated with another owner',
377+
serializedPath,
356378
)
357379
}
358380

359-
for (const foreignRecord of initialForeignEntries) {
381+
for (const foreignRecord of initialForeignRecords) {
360382
const foreignKey = foreignRecord[kPrimaryKey]
361383

362384
invariant.as(
@@ -448,6 +470,25 @@ export abstract class Relation {
448470
return result
449471
}
450472

473+
#getOtherOwnerForRecords(
474+
foreignRecords: Array<RecordType>,
475+
): RecordType | undefined {
476+
const serializedPath = this.path.join('.')
477+
478+
return this.ownerCollection.findFirst((q) => {
479+
return q.where((otherOwner) => {
480+
const otherOwnerRelations = otherOwner[kRelationMap]
481+
const otherOwnerRelation = otherOwnerRelations.get(serializedPath)
482+
483+
// Forego any other relation comparisons since the same collection
484+
// shares the relation definition at the same property path.
485+
return foreignRecords.some((foreignRecord) => {
486+
return otherOwnerRelation.foreignKeys.has(foreignRecord[kPrimaryKey])
487+
})
488+
})
489+
})
490+
}
491+
451492
#createErrorDetails(): RelationErrorDetails {
452493
return {
453494
path: this.path,

tests/relations/one-to-one.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ it('applies relation to records created before the relation is defined', async (
241241
})
242242
})
243243

244-
it('supports unique one-to-one relations', async () => {
244+
it('supports creating unique one-to-one relations', async () => {
245245
const userSchema = z.object({ id: z.number() })
246246
const postSchema = z.object({
247247
title: z.string(),
@@ -274,7 +274,45 @@ it('supports unique one-to-one relations', async () => {
274274
.soft(posts.findFirst((q) => q.where({ author: { id: updatedUser!.id } })))
275275
.toEqual(post)
276276

277-
const anotherUser = await users.create({ id: 2 })
277+
const anotherUser = await users.create({ id: 5 })
278+
await expect(
279+
posts.update(post, {
280+
data(post) {
281+
// This must not error since the provided foreign record (user)
282+
// is not associated with any owners (posts).
283+
post.author = anotherUser
284+
},
285+
}),
286+
'Updates the unique relational property',
287+
).resolves.toEqual({
288+
title: 'First',
289+
author: anotherUser,
290+
})
291+
})
292+
293+
it('supports updating unique one-to-one relations', async () => {
294+
const userSchema = z.object({ id: z.number() })
295+
const postSchema = z.object({
296+
title: z.string(),
297+
author: userSchema,
298+
})
299+
300+
const users = new Collection({ schema: userSchema })
301+
const posts = new Collection({ schema: postSchema })
302+
303+
posts.defineRelations(({ one }) => ({
304+
author: one(users, { unique: true }),
305+
}))
306+
307+
const user = await users.create({ id: 1 })
308+
const post = await posts.create({ title: 'First', author: user })
309+
310+
expect.soft(post.author).toEqual(user)
311+
expect
312+
.soft(posts.findFirst((q) => q.where({ author: { id: user.id } })))
313+
.toEqual(post)
314+
315+
const anotherUser = await users.create({ id: 5 })
278316
await expect(
279317
posts.update(post, {
280318
data(post) {
@@ -307,6 +345,7 @@ it('errors when creating a unique relation with a foreign record that has alread
307345
const user = await users.create({ id: 1 })
308346
await posts.create({ title: 'First', author: user })
309347

348+
// Cannot create another post referencing the same `user` as the author.
310349
await expect(posts.create({ title: 'Second', author: user })).rejects.toThrow(
311350
new RelationError(
312351
`Failed to create a unique relation at "author": the foreign record is already associated with another owner`,

0 commit comments

Comments
 (0)