From c598bf503d16737d01e2c2f5bfee1f378c1cd93e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 19 Feb 2020 15:56:00 +0100 Subject: [PATCH] Recursive deletion of asset folders. (#490) --- .../Assets/MongoAssetFolderRepository.cs | 18 ++- .../Assets/MongoAssetRepository.cs | 12 ++ .../Assets/RecursiveDeleter.cs | 109 +++++++++++++++++ .../Repositories/IAssetFolderRepository.cs | 3 + .../Assets/Repositories/IAssetRepository.cs | 2 + .../Assets/AssetFoldersController.cs | 2 +- .../Squidex/Config/Domain/AssetServices.cs | 3 + .../Assets/RecursiveDeleterTests.cs | 110 ++++++++++++++++++ .../TestSuite.ApiTests/AssetTests.cs | 31 +++++ .../TestSuite.Shared/Fixtures/AssetFixture.cs | 4 +- 10 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs index b34286e93..632a7d523 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs @@ -6,6 +6,8 @@ // ========================================================================== using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; @@ -45,12 +47,24 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { using (Profiler.TraceMethod("QueryAsyncByQuery")) { - var assetFolders = + var assetFolderEntities = await Collection .Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).SortBy(x => x.FolderName) .ToListAsync(); - return ResultList.Create(assetFolders.Count, assetFolders); + return ResultList.Create(assetFolderEntities.Count, assetFolderEntities); + } + } + + public async Task> QueryChildIdsAsync(Guid appId, Guid parentId) + { + using (Profiler.TraceMethod()) + { + var assetFolderEntities = + await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id) + .ToListAsync(); + + return assetFolderEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index a9454ddbb..06c44509f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -111,6 +111,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } + public async Task> QueryChildIdsAsync(Guid appId, Guid parentId) + { + using (Profiler.TraceMethod()) + { + var assetEntities = + await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id) + .ToListAsync(); + + return assetEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); + } + } + public async Task> QueryAsync(Guid appId, HashSet ids) { using (Profiler.TraceMethod("QueryAsyncByIds")) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs new file mode 100644 index 000000000..c687d9183 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/RecursiveDeleter.cs @@ -0,0 +1,109 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class RecursiveDeleter : IEventConsumer + { + private readonly ICommandBus commandBus; + private readonly IAssetRepository assetRepository; + private readonly IAssetFolderRepository assetFolderRepository; + private readonly ISemanticLog log; + private readonly string folderDeletedType; + + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return "^assetFolder\\-"; } + } + + public RecursiveDeleter( + ICommandBus commandBus, + IAssetRepository assetRepository, + IAssetFolderRepository assetFolderRepository, + TypeNameRegistry typeNameRegistry, + ISemanticLog log) + { + Guard.NotNull(commandBus); + Guard.NotNull(assetRepository); + Guard.NotNull(assetFolderRepository); + Guard.NotNull(typeNameRegistry); + Guard.NotNull(log); + + this.commandBus = commandBus; + this.assetRepository = assetRepository; + this.assetFolderRepository = assetFolderRepository; + this.log = log; + + folderDeletedType = typeNameRegistry.GetName(); + } + + public Task ClearAsync() + { + return TaskHelper.Done; + } + + public bool Handles(StoredEvent @event) + { + return @event.Data.Type == folderDeletedType; + } + + public async Task On(Envelope @event) + { + if (@event.Payload is AssetFolderDeleted folderDeleted) + { + async Task PublishAsync(SquidexCommand command) + { + try + { + command.Actor = folderDeleted.Actor; + + await commandBus.PublishAsync(command); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "DeleteAssetsRecursive") + .WriteProperty("status", "Failed")); + } + } + + var appId = folderDeleted.AppId; + + var childAssetFolders = await assetFolderRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId); + + foreach (var assetFolderId in childAssetFolders) + { + await PublishAsync(new DeleteAssetFolder { AssetFolderId = assetFolderId }); + } + + var childAssets = await assetRepository.QueryChildIdsAsync(appId.Id, folderDeleted.AssetFolderId); + + foreach (var assetId in childAssets) + { + await PublishAsync(new DeleteAsset { AssetId = assetId }); + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs index f1bdac3cc..614619828 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetFolderRepository.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -15,6 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { Task> QueryAsync(Guid appId, Guid parentId); + Task> QueryChildIdsAsync(Guid appId, Guid parentId); + Task FindAssetFolderAsync(Guid id); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 55770eb87..42ee6aea1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories Task> QueryIdsAsync(Guid appId, HashSet ids); + Task> QueryChildIdsAsync(Guid appId, Guid parentId); + Task FindAssetAsync(Guid id); Task FindAssetBySlugAsync(Guid appId, string slug); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs index 5a2cf28cb..d73932936 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs @@ -84,7 +84,7 @@ namespace Squidex.Areas.Api.Controllers.Assets var response = await InvokeCommandAsync(app, command); - return Ok(response); + return CreatedAtAction(nameof(GetAssetFolders), new { parentId = request.ParentId, app }, response); } /// diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index 3765dc695..769f59c1d 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -28,6 +28,9 @@ namespace Squidex.Config.Domain services.Configure( config.GetSection("assets")); + services.AddTransientAs() + .As(); + services.AddTransientAs() .AsSelf(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs new file mode 100644 index 000000000..b8e85caa9 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RecursiveDeleterTests.cs @@ -0,0 +1,110 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class RecursiveDeleterTests + { + private readonly TypeNameRegistry typeNameRegistry = new TypeNameRegistry(); + private readonly ISemanticLog log = A.Fake(); + private readonly IAssetRepository assetRepository = A.Fake(); + private readonly IAssetFolderRepository assetFolderRepository = A.Fake(); + private readonly ICommandBus commandBus = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly RecursiveDeleter sut; + + public RecursiveDeleterTests() + { + typeNameRegistry.Map(typeof(AssetFolderDeleted)); + + sut = new RecursiveDeleter(commandBus, assetRepository, assetFolderRepository, typeNameRegistry, log); + } + + [Fact] + public async Task Should_do_nothing_on_clear() + { + await sut.ClearAsync(); + } + + [Fact] + public async Task Should_invoke_delete_commands_for_all_subfolders() + { + var @event = new AssetFolderDeleted { AppId = appId, AssetFolderId = Guid.NewGuid() }; + + var childFolderId1 = Guid.NewGuid(); + var childFolderId2 = Guid.NewGuid(); + + A.CallTo(() => assetFolderRepository.QueryChildIdsAsync(appId.Id, @event.AssetFolderId)) + .Returns(new List { childFolderId1, childFolderId2 }); + + await sut.On(Envelope.Create(@event)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetFolderId == childFolderId1))) + .MustHaveHappened(); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetFolderId == childFolderId2))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_invoke_delete_commands_for_all_assets() + { + var @event = new AssetFolderDeleted { AppId = appId, AssetFolderId = Guid.NewGuid() }; + + var childId1 = Guid.NewGuid(); + var childId2 = Guid.NewGuid(); + + A.CallTo(() => assetRepository.QueryChildIdsAsync(appId.Id, @event.AssetFolderId)) + .Returns(new List { childId1, childId2 }); + + await sut.On(Envelope.Create(@event)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetId == childId1))) + .MustHaveHappened(); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetId == childId2))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_ignore_exceptions() + { + var @event = new AssetFolderDeleted { AppId = appId, AssetFolderId = Guid.NewGuid() }; + + var childId1 = Guid.NewGuid(); + var childId2 = Guid.NewGuid(); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetId == childId1))) + .Throws(new InvalidOperationException()); + + A.CallTo(() => assetRepository.QueryChildIdsAsync(appId.Id, @event.AssetFolderId)) + .Returns(new List { childId1, childId2 }); + + await sut.On(Envelope.Create(@event)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.AssetId == childId2))) + .MustHaveHappened(); + + A.CallTo(() => log.Log(SemanticLogLevel.Error, None.Value, A>.Ignored)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index ec414e1e0..c30e9be78 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -188,5 +188,36 @@ namespace TestSuite.ApiTests Assert.Contains(assets_2.Items, x => x.Id == asset_1.Id); } + + [Fact] + public async Task Should_delete_recursively() + { + // STEP 1: Create asset folder + var createRequest1 = new CreateAssetFolderDto { FolderName = "folder1" }; + + var folder_1 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest1); + + + // STEP 2: Create nested asset folder + var createRequest2 = new CreateAssetFolderDto { FolderName = "subfolder", ParentId = folder_1.Id }; + + var folder_2 = await _.Assets.PostAssetFolderAsync(_.AppName, createRequest2); + + + // STEP 3: Create asset in folder + var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png", null, folder_2.Id); + + + // STEP 4: Delete folder. + await _.Assets.DeleteAssetFolderAsync(_.AppName, folder_1.Id.ToString()); + + + // STEP 5: Wait for recursive deleter to delete the asset. + await Task.Delay(5000); + + var ex = await Assert.ThrowsAnyAsync(() => _.Assets.GetAssetAsync(_.AppName, asset_1.Id.ToString())); + + Assert.Equal(404, ex.StatusCode); + } } } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs index be68f6d95..e422cbad7 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs @@ -55,7 +55,7 @@ namespace TestSuite.Fixtures } } - public async Task UploadFileAsync(string path, string mimeType, string fileName = null) + public async Task UploadFileAsync(string path, string mimeType, string fileName = null, Guid? parentId = null) { var fileInfo = new FileInfo(path); @@ -63,7 +63,7 @@ namespace TestSuite.Fixtures { var upload = new FileParameter(stream, fileName ?? RandomName(fileInfo), mimeType); - return await Assets.PostAssetAsync(AppName, upload); + return await Assets.PostAssetAsync(AppName, upload, parentId); } }