diff --git a/src/collection.ts b/src/collection.ts index 484d104..b31e9ed 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -761,8 +761,17 @@ export class Collection { prevRecord, ) + /** + * @note Build a draft input where relational getters are replaced with + * plain data descriptors holding their resolved values. `mutative` cannot + * observe mutations through getters (https://github.com/unadlib/mutative/issues/157), + * so a `push` on `draft.posts` is lost unless `posts` is a plain property. + * Live foreign record references (with their internal symbols) are preserved. + */ + const draftInput = this.#buildDraftInput(prevRecord) + const [maybeNextRecord, patches, inversePatches] = await createDraft( - prevRecord, + draftInput, updateData, { strict: false, @@ -793,13 +802,56 @@ export class Collection { // so the hooks could reverse some of them. const patchesToUndo: Array = [] + /** + * @note Collapse per-index patches under a relation path into a single + * relation-level event. `draft.posts.push(x)` emits `{path: ['posts', N]}`, + * but relation handlers expect `{path: ['posts'], nextValue: }`. + */ + const relationPaths: Array> = [] + for (const serializedPath of prevRecord[kRelationMap].keys()) { + relationPaths.push(serializedPath.split('.')) + } + + type RelationPatchGroup = { + relationPath: Array + patchIndices: Array + } + const relationGroups = new Map() + const passthroughIndices: Array = [] + for (let i = 0; i < patches.length; i++) { const patch = patches[i] - if (!patch) { continue } + const matchingRelationPath = relationPaths.find((relationPath) => { + if (patch.path.length !== relationPath.length + 1) { + return false + } + if (!relationPath.every((key, index) => key === patch.path[index])) { + return false + } + const nextSegment = patch.path[relationPath.length] + return typeof nextSegment === 'number' || nextSegment === 'length' + }) + + if (matchingRelationPath) { + const groupKey = matchingRelationPath.join('.') + const group = relationGroups.get(groupKey) ?? { + relationPath: matchingRelationPath, + patchIndices: [], + } + group.patchIndices.push(i) + relationGroups.set(groupKey, group) + } else { + passthroughIndices.push(i) + } + } + + for (const i of passthroughIndices) { + const patch = patches[i]! + const updateEvent = new TypedEvent('update', { data: { prevRecord: frozenPrevRecord, @@ -826,6 +878,35 @@ export class Collection { } } + for (const group of relationGroups.values()) { + const updateEvent = new TypedEvent('update', { + data: { + prevRecord: frozenPrevRecord, + nextRecord: maybeNextRecord, + path: group.relationPath, + prevValue: get(prevRecord, group.relationPath), + nextValue: get(maybeNextRecord, group.relationPath), + }, + }) + + this.hooks.emit(updateEvent) + + if (updateEvent.defaultPrevented) { + for (const i of group.patchIndices) { + const inversePatch = inversePatches[i] + + invariant( + inversePatch != null, + 'Failed to update a record (%j): missing inverse patch at index %d', + prevRecord, + i, + ) + + patchesToUndo.push(inversePatch) + } + } + } + const nextRecord = patchesToUndo.length > 0 ? apply(maybeNextRecord, patchesToUndo) @@ -859,6 +940,34 @@ export class Collection { return finalRecord } + /** + * Build a draftable copy of the record where relational getters are replaced + * with data descriptors holding their resolved values. `mutative` cannot + * observe mutations through getters (https://github.com/unadlib/mutative/issues/157), + * so a `push` on `draft.posts` is lost unless `posts` is a plain property. + * Live foreign record references (with their internal symbols) are preserved + * so downstream hooks can resolve primary keys on the drafted elements. + */ + #buildDraftInput(record: T): T { + const clone = cloneWithInternals( + record, + ({ key, descriptor }) => + typeof key === 'symbol' && descriptor.get == null, + ) + + for (const serializedPath of record[kRelationMap].keys()) { + const path = serializedPath.split('.') + definePropertyAtPath(clone, path, { + value: get(record, path), + writable: true, + enumerable: true, + configurable: true, + }) + } + + return clone + } + /** * Returns a reproducible collection ID number based on the collection * creation order. Collection ID has to be reproducible across runtimes diff --git a/src/relation.ts b/src/relation.ts index 0470f7e..6eca9d4 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -238,15 +238,48 @@ export abstract class Relation { return } + /** + * @note For `Many` relations the first trailing segment is the array index + * selecting a specific foreign record. Identify that record and apply the + * remaining path to it. For `One` relations the whole trailing path applies + * to the single associated foreign record. + */ + let targetForeignKey: string | undefined + let foreignUpdatePath: Array + + if (this instanceof Many) { + const indexSegment = update.path[path.length] + if (typeof indexSegment !== 'number') { + return + } + const resolved = this.resolve(this.foreignKeys) + if (!Array.isArray(resolved)) { + return + } + const targetRecord = resolved[indexSegment] + if (!isRecord(targetRecord)) { + return + } + targetForeignKey = targetRecord[kPrimaryKey] + foreignUpdatePath = update.path.slice(path.length + 1) + } else { + foreignUpdatePath = update.path.slice(path.length) + } + + if (foreignUpdatePath.length === 0) { + return + } + event.preventDefault() event.stopImmediatePropagation() - const foreignUpdatePath = update.path.slice(path.length) - for (const foreignCollection of this.foreignCollections) { foreignCollection.updateMany( (q) => { return q.where((record) => { + if (targetForeignKey != null) { + return record[kPrimaryKey] === targetForeignKey + } return this.foreignKeys.has(record[kPrimaryKey]) }) }, diff --git a/tests/relations/one-to-many.test.ts b/tests/relations/one-to-many.test.ts index aea1c2b..4af7769 100644 --- a/tests/relations/one-to-many.test.ts +++ b/tests/relations/one-to-many.test.ts @@ -230,6 +230,36 @@ it('updates a one-to-many relation when the relational property is reassigned to }) }) +it('updates a one-to-many relation when the relational property is mutated via push', async () => { + const users = new Collection({ schema: userSchema }) + const posts = new Collection({ schema: postSchema }) + + users.defineRelations(({ many }) => ({ + posts: many(posts), + })) + + const firstPost = await posts.create({ title: 'First' }) + const user = await users.create({ id: 1, posts: [firstPost] }) + + const secondPost = await posts.create({ title: 'Second' }) + + const updatedUser = await users.update(user, { + data(draft) { + draft.posts.push(secondPost) + }, + }) + + expect(updatedUser).toEqual({ + id: 1, + posts: [{ title: 'First' }, { title: 'Second' }], + }) + + expect(users.findFirst((q) => q.where({ id: 1 }))).toEqual({ + id: 1, + posts: [{ title: 'First' }, { title: 'Second' }], + }) +}) + it('updates a one-to-many relation when the referenced record is dissociated', async () => { const users = new Collection({ schema: userSchema }) const posts = new Collection({ schema: postSchema })