Skip to content
Open
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
59 changes: 59 additions & 0 deletions src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,65 @@ await test('destroy', async (t) => {
assert.equal(db.data[COMMENTS].length, 0)
})

await t.test('keeps items whose foreign key was already null', async () => {
db.data = {
posts: [post1, post2],
comments: [
{ id: '1', postId: post1.id },
{ id: '2', postId: null },
{ id: '3', postId: post2.id },
],
}

await service.destroyById(POSTS, post1.id, [COMMENTS])

assert.deepEqual(db.data[COMMENTS], [
{ id: '2', postId: null },
{ id: '3', postId: post2.id },
])
})

await t.test('deletes dependents of dependents', async () => {
const remaining = {
villages: [{ id: '2' }],
houses: [{ id: '2', villageId: '2' }],
citizens: [{ id: '2', houseId: '2' }],
}

for (const dependents of [
['houses', 'citizens'],
['citizens', 'houses'],
]) {
db.data = {
villages: [{ id: '1' }, { id: '2' }],
houses: [
{ id: '1', villageId: '1' },
{ id: '2', villageId: '2' },
],
citizens: [
{ id: '1', houseId: '1' },
{ id: '2', houseId: '2' },
],
}

await service.destroyById('villages', '1', dependents)

assert.deepEqual(db.data, remaining, `dependents listed as ${dependents.join(',')}`)
}
})

await t.test('nullifies foreign keys pointing at deleted dependents', async () => {
db.data = {
villages: [{ id: '1' }],
houses: [{ id: '1', villageId: '1' }],
pets: [{ id: '1', houseId: '1' }],
}

await service.destroyById('villages', '1', ['houses'])

assert.deepEqual(db.data['pets'], [{ id: '1', houseId: null }])
})

await t.test('ignores unknown resources', async () => {
assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)
assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)
Expand Down
73 changes: 54 additions & 19 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,36 +46,71 @@ function embed(db: Low<Data>, name: string, item: Item, related: string): Item {
return { ...item, [related]: relatedItems }
}

function nullifyForeignKey(db: Low<Data>, name: string, id: string) {
const foreignKey = `${inflection.singularize(name)}Id`
function foreignKeyOf(name: string): string {
return `${inflection.singularize(name)}Id`
}

// Ids removed from each resource, so that a dependent can be matched against
// every resource deleted so far and not just against the requested one
type DeletedIds = Map<string, Set<string>>

function isDependentOf(item: Item, deleted: DeletedIds): boolean {
for (const [name, ids] of deleted) {
const value = item[foreignKeyOf(name)]
if (typeof value === 'string' && ids.has(value)) return true
}

return false
}

function nullifyForeignKeys(db: Low<Data>, deleted: DeletedIds) {
Object.entries(db.data).forEach(([key, items]) => {
// Skip
if (key === name) return
if (!Array.isArray(items)) return

for (const [name, ids] of deleted) {
// Skip
if (key === name) continue

// Nullify
if (Array.isArray(items)) {
// Nullify
const foreignKey = foreignKeyOf(name)
items.forEach((item) => {
if (item[foreignKey] === id) {
const value = item[foreignKey]
if (typeof value === 'string' && ids.has(value)) {
item[foreignKey] = null
}
})
}
})
}

function deleteDependents(db: Low<Data>, name: string, dependents: string[]) {
const foreignKey = `${inflection.singularize(name)}Id`
function deleteDependents(db: Low<Data>, name: string, deleted: DeletedIds, dependents: string[]) {
// A dependent can itself be the parent of another one, so keep going until
// nothing new is deleted. A chain can be at most as deep as the number of
// dependents, and this makes the result independent of the order they are
// listed in.
for (let pass = 0; pass < dependents.length; pass++) {
let deletedAny = false

Object.entries(db.data).forEach(([key, items]) => {
// Skip
if (key === name || !dependents.includes(key)) return
for (const dependent of dependents) {
const items = db.data[dependent]

// Skip
if (dependent === name || !Array.isArray(items)) continue

// Delete items related to an already deleted one
const ids = deleted.get(dependent) ?? new Set<string>()
db.data[dependent] = items.filter((item) => {
if (!isDependentOf(item, deleted)) return true

// Delete if foreign key is null
if (Array.isArray(items)) {
db.data[key] = items.filter((item) => item[foreignKey] !== null)
deletedAny = true
if (typeof item['id'] === 'string') ids.add(item['id'])
return false
})
deleted.set(dependent, ids)
}
})

if (!deletedAny) break
}
}

export class Service {
Expand Down Expand Up @@ -212,9 +247,9 @@ export class Service {
const index = items.indexOf(item)
items.splice(index, 1)

nullifyForeignKey(this.#db, name, id)
const dependents = ensureArray(dependent)
deleteDependents(this.#db, name, dependents)
const deleted: DeletedIds = new Map([[name, new Set([id])]])
deleteDependents(this.#db, name, deleted, ensureArray(dependent))
nullifyForeignKeys(this.#db, deleted)

await this.#db.write()
return item
Expand Down