Skip to content

Delete the members of an aggregate which are referenced by their owner - #934

Open
sven-n wants to merge 2 commits into
masterfrom
claude/pr927-cascade-delete-review-dk6qyh
Open

Delete the members of an aggregate which are referenced by their owner#934
sven-n wants to merge 2 commits into
masterfrom
claude/pr927-cascade-delete-review-dk6qyh

Conversation

@sven-n

@sven-n sven-n commented Sep 4, 2026

Copy link
Copy Markdown
Member

Fixes #933.

A member of an aggregate which is a collection holds its foreign key at the child, so the generated ON DELETE CASCADE removes it together with its owner. A member which is referenced by the owner (one-to-one) holds the foreign key at the owner instead, and a cascade only ever runs from the referenced row to the referencing one — so deleting the owner leaves the referenced row behind, unreachable. The cascade we generate for those 28 relationships today says "deleting the item storage deletes the character", which is the opposite of what's meant.

The most visible case is an item storage: every deleted character leaves its inventory behind, and with it every item lying in it. That happens in the normal game path (DeleteCharacterAction), not only in the bot purge, which worked around it by deleting the storages itself.

Since no foreign key can express the other direction, this uses a trigger — generated from the very annotation that states the intent, so it stays correct by construction: mark a member as [MemberOfAggregate] and its delete behaviour is right, whichever side holds the foreign key.

What's in here

  • EfCoreModelGenerator generates AggregateDeleteTriggers.CreateScript next to the cascades it already emits: one delete_owned_row() function per schema, plus one after delete trigger per one-to-one member. The list is sorted, so the generated file is stable.
  • A migration applies that script and deletes the item storages which were orphaned before the triggers existed (their items follow through the storage's own cascade). The script is copied into the migration on purpose — a migration has to keep applying what it applied when it was added, otherwise a fresh database would run it against columns which don't exist yet.
  • A test fails when the generated script no longer matches what any migration applies, i.e. when someone adds a member of an aggregate and forgets the migration.
  • BotGenerator no longer deletes the storages itself. It has to stop: EF orders the character delete before the storage delete, so the trigger has already removed the row when EF's own DELETE runs, and the save would fail with a DbUpdateConcurrencyException.

Triggers fire for rows deleted by a foreign key cascade as well, so delete from data."Account" alone now cleans up the characters and each of their inventories — a shallow or bulk delete is covered, which the application-side workaround could never do without loading the whole graph.

Two members are skipped

[MemberOfAggregate] is not a clean "owned exclusively" list today, and a trigger would make that dangerous rather than merely useless. Members whose target type is shared — it appears in a GameConfiguration collection or in the generator's StandaloneTypes — get no trigger and keep today's behaviour:

  • Buff.MagicEffectDefinitionMagicEffectDefinition is in GameConfiguration.MagicEffects and also referenced by Skill.MagicEffectDef and ItemDefinition.ConsumeEffect.
  • Skill.MasterDefinitionMasterSkillDefinition is in StandaloneTypes.

Two more are included but worth a second opinion from someone who knows the data better than the code shows: MiniGameChangeEvent.SpawnArea (a MonsterSpawnArea, which also appears in GameMapDefinition.MonsterSpawns) and QuestReward.ItemReward. Say the word and I'll exclude either.

Not in here

Flipping those 28 DeleteBehavior.Cascade to NoAction, which would also be right — the database currently carries a rule meaning the opposite of the aggregate semantics, harmless only because EF happens to order deletes dependent-first. It changes the model, so it needs a scaffolded migration with 28 foreign keys dropped and re-added plus a snapshot update; better as its own change.

Testing

The new unit test covers the generator/migration drift. What I could not verify here: this environment has no .NET SDK and no PostgreSQL, so the solution was not built and the migration was not executed against a database. The SQL was derived from EntityDataContextModelSnapshot.cs and every schema, table and column name in it was checked against that snapshot, and both script copies (the generated constant and the migration) were verified byte-identical. Still worth one run of the migration against a real database before merging.

Note this touches BotGenerator.DeleteAllBotsAsync, which #927 also changes — whichever lands first, I'm happy to rebase the other.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN


Generated by Claude Code

A member of an aggregate which is a collection holds its foreign key at
the child, so "on delete cascade" removes it together with its owner. A
member which is referenced by the owner (one-to-one) holds the foreign
key at the owner instead, and a cascade only ever runs from the
referenced row to the referencing one - so deleting the owner leaves the
referenced row behind, unreachable. The generated cascade of these 28
relationships therefore says "deleting the item storage deletes the
character", which is the opposite of what's meant.

The most visible case is an item storage: every deleted character left
its inventory behind, and with it every item lying in it. That happens
in the normal game path (DeleteCharacterAction), not just in the bot
purge, which worked around it by deleting the storages itself.

The source generator now emits the delete triggers for these members
along with the cascades it already generates, so the invariant is
maintained by the same annotation: mark a member of an aggregate and its
delete behaviour is correct, whichever side holds the foreign key.
Members which reference a shared type are skipped - deleting them would
take data away from their other owners.

- EfCoreModelGenerator generates AggregateDeleteTriggers.CreateScript,
  a trigger function plus one trigger per member (26 of 28; Buff.
  MagicEffectDefinition and Skill.MasterDefinition reference a shared
  type and need an annotation review first).
- A migration applies the script and removes the item storages which
  were orphaned before the triggers existed.
- A test fails when a model change makes the generated script differ
  from what the migrations apply, so a new member can't be forgotten.
- BotGenerator no longer deletes the storages itself: the trigger has
  already removed the rows when EF saves, which would fail the save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
#927 moved the deletion of a single bot account into TryDeleteBotAccountAsync.
Kept that structure and removed the manual deletion of the item storages there
instead: the delete triggers remove them now, and EF's own delete of the storage
would find the row already gone and fail the save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 4, 2026

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8154d96
Status: ✅  Deploy successful!
Preview URL: https://48613988.openmudocs.pages.dev
Branch Preview URL: https://claude-pr927-cascade-delete.openmudocs.pages.dev

View logs

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.

Deleting an entity orphans the members of its aggregate which it references itself (item storages, appearance data, ...)

2 participants