-
Notifications
You must be signed in to change notification settings - Fork 614
Delete the members of a member of an aggregate, too #934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sven-n
wants to merge
6
commits into
master
Choose a base branch
from
claude/pr927-cascade-delete-review-dk6qyh
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
3e456d4
Delete the members of an aggregate which are referenced by their owner
claude 8154d96
Merge branch 'master' into claude/pr927-cascade-delete-review-dk6qyh
claude 6dc5556
Delete the members of a member of an aggregate, too
claude 80ba05c
Merge remote-tracking branch 'origin/master' into claude/pr927-cascad…
claude 8720603
Don't let the delete recursion reach into a shared configuration object
claude 5dfba14
Clean up the orphans of the whole aggregate, and cover the deletion b…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
101 changes: 101 additions & 0 deletions
101
src/Persistence/EntityFramework/Migrations/20260922183000_CleanUpOrphanedAggregateMembers.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| // <copyright file="20260922183000_CleanUpOrphanedAggregateMembers.cs" company="MUnique"> | ||
| // Licensed under the MIT License. See LICENSE file in the project root for full license information. | ||
| // </copyright> | ||
|
|
||
| #nullable disable | ||
|
|
||
| namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations | ||
| { | ||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||
| using Microsoft.EntityFrameworkCore.Migrations; | ||
|
|
||
| /// <inheritdoc /> | ||
| [DbContext(typeof(EntityDataContext))] | ||
| [Migration("20260922183000_CleanUpOrphanedAggregateMembers")] | ||
| public partial class CleanUpOrphanedAggregateMembers : Migration | ||
| { | ||
| /// <inheritdoc /> | ||
| protected override void Up(MigrationBuilder migrationBuilder) | ||
| { | ||
| // Removes the rows which were orphaned while EntityFrameworkContextBase.ForEachAggregate | ||
| // did not recurse into the members of a member: deleting an account removed its characters, | ||
| // but not their inventories - and an inventory is referenced BY its character, so no delete | ||
| // cascade of the database ever reached it. The same happened to every member which is | ||
| // referenced by its owner and sits two levels below the deleted object. | ||
| // The children of a removed row follow through its own cascade. | ||
| migrationBuilder.Sql( | ||
| @"do $$ | ||
| declare | ||
| target record; | ||
| reference record; | ||
| conditions text; | ||
| begin | ||
| -- A row of these tables is only reachable through a reference held by its owner, so a row which | ||
| -- nothing points at is unreachable. Tables whose rows can also be owned through a foreign key of | ||
| -- their own are deliberately not listed (Item, PowerUpDefinition, MonsterSpawnArea, | ||
| -- CastleSiegeZoneDefinition, MagicEffectDefinition): an item in a storage holds that reference | ||
| -- itself, so nothing points at it is true for almost every item of every player. | ||
| for target in | ||
| select * from (values | ||
| ('config', 'AreaSkillSettings', '{}'::text[]), | ||
| ('config', 'BattleZoneDefinition', '{}'::text[]), | ||
| ('config', 'CastleSiegeConfiguration', array['CastleSiegeNpcDefinition', 'CastleSiegeStateScheduleEntry', 'CastleSiegeUpgradeDefinition', 'CastleSiegeZoneDefinition']::text[]), | ||
| ('config', 'DuelConfiguration', array['DuelArea']::text[]), | ||
| ('config', 'MasterSkillDefinition', '{}'::text[]), | ||
| ('config', 'PowerUpDefinitionValue', array['AttributeRelationship']::text[]), | ||
| ('config', 'Rectangle', '{}'::text[]), | ||
| ('config', 'SimpleCraftingSettings', array['ItemCraftingRequiredItem', 'ItemCraftingResultItem']::text[]), | ||
| ('config', 'SkillComboDefinition', array['SkillComboStep']::text[]), | ||
| ('data', 'AppearanceData', array['ItemAppearance']::text[]), | ||
| ('data', 'ItemStorage', array['Item']::text[]) | ||
| ) as v(schema_name, table_name, owned_tables) | ||
| loop | ||
| conditions := ''; | ||
|
|
||
| -- Every foreign key which points at the target, read from the catalog instead of a written | ||
| -- list: one which is missed would delete rows which are in use. The tables of the target's | ||
| -- own aggregate are skipped - they point at their owner, and an owner which only its own | ||
| -- members still reference is exactly what is unreachable. | ||
| for reference in | ||
| select source_schema.nspname as source_schema, | ||
| source_table.relname as source_table, | ||
| source_column.attname as source_column, | ||
| target_column.attname as target_column | ||
| from pg_constraint c | ||
| join pg_class source_table on source_table.oid = c.conrelid | ||
| join pg_namespace source_schema on source_schema.oid = source_table.relnamespace | ||
| join pg_class referenced_table on referenced_table.oid = c.confrelid | ||
| join pg_namespace referenced_schema on referenced_schema.oid = referenced_table.relnamespace | ||
| join lateral unnest(c.conkey, c.confkey) as key_columns(source_attnum, target_attnum) on true | ||
| join pg_attribute source_column | ||
| on source_column.attrelid = source_table.oid and source_column.attnum = key_columns.source_attnum | ||
| join pg_attribute target_column | ||
| on target_column.attrelid = referenced_table.oid and target_column.attnum = key_columns.target_attnum | ||
| where c.contype = 'f' | ||
| and referenced_schema.nspname::text = target.schema_name | ||
| and referenced_table.relname::text = target.table_name | ||
| and not (source_table.relname::text = any (target.owned_tables)) | ||
| loop | ||
| conditions := conditions || format( | ||
| ' and not exists (select 1 from %I.%I r where r.%I = x.%I)', | ||
| reference.source_schema, reference.source_table, reference.source_column, reference.target_column); | ||
| end loop; | ||
|
|
||
| if conditions = '' then | ||
| -- Nothing points at this table at all, so an orphan cannot be told from a row in use. | ||
| continue; | ||
| end if; | ||
|
|
||
| execute format('delete from %I.%I x where true%s', target.schema_name, target.table_name, conditions); | ||
| end loop; | ||
| end $$; | ||
| "); | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override void Down(MigrationBuilder migrationBuilder) | ||
| { | ||
| // Nothing to do - the removed rows were unreachable and can't be restored. | ||
| } | ||
| } | ||
| } |
97 changes: 97 additions & 0 deletions
97
tests/MUnique.OpenMU.Persistence.Initialization.Tests/AggregateDeletionEfCoreTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| // <copyright file="AggregateDeletionEfCoreTests.cs" company="MUnique"> | ||
| // Licensed under the MIT License. See LICENSE file in the project root for full license information. | ||
| // </copyright> | ||
|
|
||
| namespace MUnique.OpenMU.Persistence.Initialization.Tests; | ||
|
|
||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.Extensions.Logging.Abstractions; | ||
| using MUnique.OpenMU.DataModel.Configuration; | ||
| using MUnique.OpenMU.DataModel.Entities; | ||
| using MUnique.OpenMU.Persistence.EntityFramework; | ||
| using ItemStorageEntity = MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage; | ||
|
|
||
| /// <summary> | ||
| /// Tests the deletion of an aggregate with the entity framework core, which requires a running postgres database. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The interesting part is what the database is left with, so this can't be tested without one. | ||
| /// </remarks> | ||
| [TestFixture] | ||
| internal class AggregateDeletionEfCoreTests | ||
| { | ||
| /// <summary> | ||
| /// Deletes an account which owns a vault and a character with an inventory, and checks that no | ||
| /// item storage is left behind. The inventory is the case which used to leak: it's referenced BY | ||
| /// the character, so no delete cascade of the database reaches it, and the traversal of the | ||
| /// aggregate didn't go deeper than the account's own members. | ||
| /// </summary> | ||
| [Test] | ||
| [Ignore("This is not a real test which should run automatically. It requires a database.")] | ||
| public async Task DeletingAnAccountDeletesTheItemStoragesOfItsCharactersAsync() | ||
| { | ||
| var contextProvider = await CreateInitializedDatabaseAsync().ConfigureAwait(false); | ||
| GameConfiguration configuration; | ||
| using (var context = contextProvider.CreateNewContext()) | ||
| { | ||
| configuration = (await context.GetAsync<GameConfiguration>().ConfigureAwait(false)).Single(); | ||
| } | ||
|
|
||
| using (var context = contextProvider.CreateNewPlayerContext(configuration)) | ||
| { | ||
| var account = context.CreateNew<Account>(); | ||
| account.LoginName = "deleteme"; | ||
| account.PasswordHash = "hash"; | ||
| account.Vault = context.CreateNew<ItemStorage>(); | ||
|
|
||
| var characterClass = configuration.CharacterClasses.First(c => c is { CanGetCreated: true, HomeMap: not null }); | ||
| var character = context.CreateNew<Character>(); | ||
| character.Name = "DeleteMe"; | ||
| character.CharacterClass = characterClass; | ||
| character.CurrentMap = characterClass.HomeMap; | ||
| character.CreateDate = DateTime.UtcNow; | ||
| character.KeyConfiguration = new byte[30]; | ||
| character.Inventory = context.CreateNew<ItemStorage>(); | ||
| account.Characters.Add(character); | ||
|
|
||
| await context.SaveChangesAsync().ConfigureAwait(false); | ||
| } | ||
|
|
||
| Assert.That(await CountItemStoragesAsync().ConfigureAwait(false), Is.EqualTo(2), "The vault and the inventory should have been created."); | ||
|
|
||
| using (var context = contextProvider.CreateNewPlayerContext(configuration)) | ||
| { | ||
| var account = await context.GetAccountByLoginNameAsync("deleteme").ConfigureAwait(false); | ||
| Assert.That(account, Is.Not.Null); | ||
| await context.DeleteAsync(account!).ConfigureAwait(false); | ||
|
|
||
| // Must not throw: nothing may delete a row which the context has marked as deleted itself. | ||
| await context.SaveChangesAsync().ConfigureAwait(false); | ||
| } | ||
|
|
||
| Assert.That(await CountItemStoragesAsync().ConfigureAwait(false), Is.EqualTo(0)); | ||
| } | ||
|
|
||
| private static async ValueTask<PersistenceContextProvider> CreateInitializedDatabaseAsync() | ||
| { | ||
| await ReCreateDatabaseAsync().ConfigureAwait(false); | ||
|
|
||
| var contextProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); | ||
| await new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory()) | ||
| .CreateInitialDataAsync(1, true).ConfigureAwait(false); | ||
|
|
||
| return contextProvider; | ||
| } | ||
|
|
||
| private static async ValueTask ReCreateDatabaseAsync() | ||
| { | ||
| var contextProvider = new PersistenceContextProvider(new NullLoggerFactory(), null); | ||
| using var update = await contextProvider.ReCreateDatabaseAsync().ConfigureAwait(false); | ||
| } | ||
|
|
||
| private static async ValueTask<int> CountItemStoragesAsync() | ||
| { | ||
| await using var context = new EntityDataContext(); | ||
| return await context.Set<ItemStorageEntity>().CountAsync().ConfigureAwait(false); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.