Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 111 additions & 2 deletions src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,8 +761,17 @@ export class Collection<Schema extends StandardSchemaV1> {
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,
Expand Down Expand Up @@ -793,13 +802,56 @@ export class Collection<Schema extends StandardSchemaV1> {
// so the hooks could reverse some of them.
const patchesToUndo: Array<Patch> = []

/**
* @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: <final array>}`.
*/
const relationPaths: Array<Array<string>> = []
for (const serializedPath of prevRecord[kRelationMap].keys()) {
relationPaths.push(serializedPath.split('.'))
}

type RelationPatchGroup = {
relationPath: Array<string>
patchIndices: Array<number>
}
const relationGroups = new Map<string, RelationPatchGroup>()
const passthroughIndices: Array<number> = []

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,
Expand All @@ -826,6 +878,35 @@ export class Collection<Schema extends StandardSchemaV1> {
}
}

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)
Expand Down Expand Up @@ -859,6 +940,34 @@ export class Collection<Schema extends StandardSchemaV1> {
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<T extends RecordType>(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
Expand Down
37 changes: 35 additions & 2 deletions src/relation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | number | symbol>

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])
})
},
Expand Down
30 changes: 30 additions & 0 deletions tests/relations/one-to-many.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading