Skip to content

fix: resolve dependents against every deleted resource - #1772

Open
HafizMMoaz wants to merge 1 commit into
typicode:mainfrom
HafizMMoaz:fix/dependent-delete
Open

fix: resolve dependents against every deleted resource#1772
HafizMMoaz wants to merge 1 commit into
typicode:mainfrom
HafizMMoaz:fix/dependent-delete

Conversation

@HafizMMoaz

Copy link
Copy Markdown

Fixes #1415

Problem

Two bugs, one root cause. destroyById nullifies foreign keys and then deletes the rows whose key is null:

nullifyForeignKey(this.#db, name, id)          // related rows get `postId: null`
deleteDependents(this.#db, name, dependents)   // drop rows where `postId === null`

1. Rows that were already null are deleted. The sweep cannot tell a row nullified by this request from one that was never attached to anything:

{
  "posts": [{ "id": "1" }, { "id": "2" }],
  "comments": [
    { "id": "a", "postId": "1" },
    { "id": "b", "postId": null },   // never belonged to a post
    { "id": "c", "postId": "2" }
  ]
}
$ curl -X DELETE 'localhost:3000/posts/1?_dependent=comments'
$ curl localhost:3000/comments
[{"id":"c","postId":"2"}]           # comment b is gone too

2. Only direct children can match, which is #1415. The sweep uses the deleted resource's own foreign key for every dependent, so a grandchild keyed by something else never matches, even when it is named explicitly:

$ curl -X DELETE 'localhost:3000/villages/1?_dependent=houses&_dependent=citizens'

deleteDependents looks for villageId on the citizens, finds none, and leaves them behind pointing at houses that no longer exist. There is currently no request that deletes them.

Fix

Deletion is now driven by ids rather than by nulls. destroyById keeps a map of resource name to the ids removed from it, seeded with the item being deleted. A dependent row is removed when its foreign key points at any id in that map, and its own id joins the map, so the next resource in the chain can be resolved against it.

The passes repeat until nothing new is removed, which makes the result independent of the order the dependents are listed in. The loop is bounded by the number of dependents, since a chain cannot be deeper than that.

Foreign keys are nullified afterwards against the same map, so rows pointing at a deleted dependent are nullified too, not just rows pointing at the deleted item.

Because nothing keys off null any more, the first bug disappears with it.

Behavior

?_dependent=houses&_dependent=citizens now deletes both levels. Anything not named is left alone, so no data the caller did not ask for is removed. I kept it opt-in rather than cascading the whole graph implicitly, since that would silently widen what an existing DELETE request deletes.

Tests

Added to the destroy suite in service.test.ts:

  • a row whose foreign key was already null survives an unrelated delete
  • a two level chain is fully deleted, asserted with the dependents listed in both orders
  • a foreign key pointing at a deleted dependent is nullified

The two existing destroy tests are unchanged and still pass. pnpm test, pnpm lint and pnpm typecheck all pass.

I also confirmed over HTTP that repeated _dependent params arrive as an array, so the request above works end to end.

Deleting a resource with `_dependent` worked by nullifying foreign keys
and then removing the rows whose key was null. That has two problems.

Rows that already held a null foreign key before the request were
indistinguishable from rows nullified by it, so deleting one post removed
every comment that was never attached to a post in the first place.

The sweep also used the deleted resource's own foreign key for all the
dependents, so only direct children could ever match. Deleting a village
with `?_dependent=houses&_dependent=citizens` looked for `villageId` on
the citizens, found none, and left them behind pointing at houses that no
longer exist.

Dependents are now matched by id against every resource deleted so far,
repeating until nothing new is removed, so a chain of dependents is
resolved whatever order it is listed in. Foreign keys are nullified
afterwards and cover rows pointing at deleted dependents too.

Dependents still have to be named explicitly, so nothing the caller did
not ask for is deleted.

Fixes typicode#1415
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incomplete deletion of related data when deleting

1 participant