diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs index b29cf1407..e5e3be6bd 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs @@ -8,7 +8,6 @@ using System.Globalization; using NodaTime; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; #pragma warning disable SA1313 // Parameter names should begin with lower-case letter 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 5a468d8fd..585eb0c40 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -206,28 +206,26 @@ public sealed partial class MongoAssetRepository : MongoRepositoryBase FindAssetBySlugAsync(DomainId appId, string slug, + public async Task FindAssetBySlugAsync(DomainId appId, string slug, bool allowDeleted, CancellationToken ct = default) { using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetBySlugAsync")) { var assetEntity = - await Collection.Find(x => x.IndexedAppId == appId && x.Slug == slug && !x.IsDeleted) + await Collection.Find(BuildFilter(appId, slug, allowDeleted)) .FirstOrDefaultAsync(ct); return assetEntity; } } - public async Task FindAssetAsync(DomainId appId, DomainId id, + public async Task FindAssetAsync(DomainId appId, DomainId id, bool allowDeleted, CancellationToken ct = default) { using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetAsync")) { - var documentId = DomainId.Combine(appId, id); - var assetEntity = - await Collection.Find(x => x.DocumentId == documentId && !x.IsDeleted) + await Collection.Find(BuildFilter(appId, id, allowDeleted)) .FirstOrDefaultAsync(ct); return assetEntity; @@ -256,6 +254,30 @@ public sealed partial class MongoAssetRepository : MongoRepositoryBase x.IsDeleted, true)); } + private static FilterDefinition BuildFilter(DomainId appId, string slug, bool allowDeleted) + { + var filter = Filter.And(Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.Slug, slug)); + + if (!allowDeleted) + { + filter = Filter.And(filter, Filter.Ne(x => x.IsDeleted, true)); + } + + return filter; + } + + private static FilterDefinition BuildFilter(DomainId appId, DomainId id, bool allowDeleted) + { + var filter = Filter.Eq(x => x.DocumentId, DomainId.Combine(appId, id)); + + if (!allowDeleted) + { + filter = Filter.And(filter, Filter.Ne(x => x.IsDeleted, true)); + } + + return filter; + } + private static FilterDefinition BuildFilter(DomainId appId, DomainId parentId) { return Filter.And( diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs index 3f8aee9e6..f983f5c96 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs @@ -40,14 +40,29 @@ public partial class AppDomainObject : DomainObject return snapshot.IsDeleted; } - protected override bool CanAcceptCreation(ICommand command) + protected override bool CanAccept(ICommand command) { - return command is AppCommandBase; + if (Snapshot.Id == default) + { + return true; + } + + return command is AppCommandBase c && c.AggregateId == Snapshot.Id; } - protected override bool CanAccept(ICommand command) + protected override bool CanAccept(ICommand command, DomainObjectState state) { - return command is AppCommand update && Equals(update?.AppId?.Id, Snapshot.Id); + switch (state) + { + case DomainObjectState.Undefined: + return command is CreateApp; + case DomainObjectState.Empty: + return command is CreateApp; + case DomainObjectState.Created: + return command is not CreateApp; + default: + return false; + } } public override Task ExecuteAsync(IAggregateCommand command, @@ -56,7 +71,7 @@ public partial class AppDomainObject : DomainObject switch (command) { case CreateApp create: - return CreateReturn(create, c => + return ApplyReturn(create, c => { GuardApp.CanCreate(c); @@ -66,7 +81,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateApp update: - return UpdateReturn(update, c => + return ApplyReturn(update, c => { GuardApp.CanUpdate(c); @@ -76,7 +91,7 @@ public partial class AppDomainObject : DomainObject }, ct); case TransferToTeam transfer: - return UpdateReturnAsync(transfer, async (c, ct) => + return ApplyReturnAsync(transfer, async (c, ct) => { await GuardApp.CanTransfer(c, Snapshot, AppProvider, ct); @@ -86,7 +101,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateAppSettings updateSettings: - return UpdateReturn(updateSettings, c => + return ApplyReturn(updateSettings, c => { GuardApp.CanUpdateSettings(c); @@ -96,7 +111,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UploadAppImage uploadImage: - return UpdateReturn(uploadImage, c => + return ApplyReturn(uploadImage, c => { GuardApp.CanUploadImage(c); @@ -106,7 +121,7 @@ public partial class AppDomainObject : DomainObject }, ct); case RemoveAppImage removeImage: - return UpdateReturn(removeImage, c => + return ApplyReturn(removeImage, c => { GuardApp.CanRemoveImage(c); @@ -116,7 +131,7 @@ public partial class AppDomainObject : DomainObject }, ct); case ConfigureAssetScripts configureAssetScripts: - return UpdateReturn(configureAssetScripts, c => + return ApplyReturn(configureAssetScripts, c => { GuardApp.CanUpdateAssetScripts(c); @@ -126,7 +141,7 @@ public partial class AppDomainObject : DomainObject }, ct); case AssignContributor assignContributor: - return UpdateReturnAsync(assignContributor, async (c, ct) => + return ApplyReturnAsync(assignContributor, async (c, ct) => { var (plan, _, _) = await UsageGate.GetPlanForAppAsync(Snapshot, false, ct); @@ -138,7 +153,7 @@ public partial class AppDomainObject : DomainObject }, ct); case RemoveContributor removeContributor: - return UpdateReturn(removeContributor, c => + return ApplyReturn(removeContributor, c => { GuardAppContributors.CanRemove(c, Snapshot); @@ -148,7 +163,7 @@ public partial class AppDomainObject : DomainObject }, ct); case AttachClient attachClient: - return UpdateReturn(attachClient, c => + return ApplyReturn(attachClient, c => { GuardAppClients.CanAttach(c, Snapshot); @@ -158,7 +173,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateClient updateClient: - return UpdateReturn(updateClient, c => + return ApplyReturn(updateClient, c => { GuardAppClients.CanUpdate(c, Snapshot); @@ -168,7 +183,7 @@ public partial class AppDomainObject : DomainObject }, ct); case RevokeClient revokeClient: - return UpdateReturn(revokeClient, c => + return ApplyReturn(revokeClient, c => { GuardAppClients.CanRevoke(c, Snapshot); @@ -178,7 +193,7 @@ public partial class AppDomainObject : DomainObject }, ct); case AddWorkflow addWorkflow: - return UpdateReturn(addWorkflow, c => + return ApplyReturn(addWorkflow, c => { GuardAppWorkflows.CanAdd(c); @@ -188,7 +203,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateWorkflow updateWorkflow: - return UpdateReturn(updateWorkflow, c => + return ApplyReturn(updateWorkflow, c => { GuardAppWorkflows.CanUpdate(c, Snapshot); @@ -198,7 +213,7 @@ public partial class AppDomainObject : DomainObject }, ct); case DeleteWorkflow deleteWorkflow: - return UpdateReturn(deleteWorkflow, c => + return ApplyReturn(deleteWorkflow, c => { GuardAppWorkflows.CanDelete(c, Snapshot); @@ -208,7 +223,7 @@ public partial class AppDomainObject : DomainObject }, ct); case AddLanguage addLanguage: - return UpdateReturn(addLanguage, c => + return ApplyReturn(addLanguage, c => { GuardAppLanguages.CanAdd(c, Snapshot); @@ -218,7 +233,7 @@ public partial class AppDomainObject : DomainObject }, ct); case RemoveLanguage removeLanguage: - return UpdateReturn(removeLanguage, c => + return ApplyReturn(removeLanguage, c => { GuardAppLanguages.CanRemove(c, Snapshot); @@ -228,7 +243,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateLanguage updateLanguage: - return UpdateReturn(updateLanguage, c => + return ApplyReturn(updateLanguage, c => { GuardAppLanguages.CanUpdate(c, Snapshot); @@ -238,7 +253,7 @@ public partial class AppDomainObject : DomainObject }, ct); case AddRole addRole: - return UpdateReturn(addRole, c => + return ApplyReturn(addRole, c => { GuardAppRoles.CanAdd(c, Snapshot); @@ -248,7 +263,7 @@ public partial class AppDomainObject : DomainObject }, ct); case DeleteRole deleteRole: - return UpdateReturn(deleteRole, c => + return ApplyReturn(deleteRole, c => { GuardAppRoles.CanDelete(c, Snapshot); @@ -258,7 +273,7 @@ public partial class AppDomainObject : DomainObject }, ct); case UpdateRole updateRole: - return UpdateReturn(updateRole, c => + return ApplyReturn(updateRole, c => { GuardAppRoles.CanUpdate(c, Snapshot); @@ -268,7 +283,7 @@ public partial class AppDomainObject : DomainObject }, ct); case DeleteApp delete: - return UpdateAsync(delete, async (c, ct) => + return ApplyAsync(delete, async (c, ct) => { await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default); @@ -276,7 +291,7 @@ public partial class AppDomainObject : DomainObject }, ct); case ChangePlan changePlan: - return UpdateReturnAsync(changePlan, async (c, ct) => + return ApplyReturnAsync(changePlan, async (c, ct) => { GuardApp.CanChangePlan(c, Snapshot, BillingPlans); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs index d15dc10c2..7fec21210 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs @@ -142,10 +142,6 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware return snapshot.IsDeleted; } - protected override bool CanRecreate() - { - return true; - } - - protected override bool CanRecreate(IEvent @event) + protected override bool IsRecreation(IEvent @event) { return @event is AssetCreated; } - protected override bool CanAcceptCreation(ICommand command) + protected override bool CanAccept(ICommand command) { - return command is AssetCommandBase; + return command is AssetCommand c && c.AppId == Snapshot.AppId && c.AssetId == Snapshot.Id; } - protected override bool CanAccept(ICommand command) + protected override bool CanAccept(ICommand command, DomainObjectState state) { - return command is AssetCommand assetCommand && - Equals(assetCommand.AppId, Snapshot.AppId) && - Equals(assetCommand.AssetId, Snapshot.Id); + switch (state) + { + case DomainObjectState.Undefined: + return command is CreateAsset; + case DomainObjectState.Empty: + return command is CreateAsset or UpsertAsset; + case DomainObjectState.Deleted: + return command is CreateAsset or UpsertAsset or DeleteAsset { Permanent: true }; + default: + return command is not CreateAsset; + } } public override Task ExecuteAsync(IAggregateCommand command, @@ -64,7 +67,7 @@ public partial class AssetDomainObject : DomainObject switch (command) { case UpsertAsset upsert: - return UpsertReturnAsync(upsert, async (c, ct) => + return ApplyReturnAsync(upsert, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -86,7 +89,7 @@ public partial class AssetDomainObject : DomainObject }, ct); case CreateAsset create: - return CreateReturnAsync(create, async (c, ct) => + return ApplyReturnAsync(create, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -101,7 +104,7 @@ public partial class AssetDomainObject : DomainObject }, ct); case AnnotateAsset annotate: - return UpdateReturnAsync(annotate, async (c, ct) => + return ApplyReturnAsync(annotate, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -111,7 +114,7 @@ public partial class AssetDomainObject : DomainObject }, ct); case UpdateAsset update: - return UpdateReturnAsync(update, async (c, ct) => + return ApplyReturnAsync(update, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -121,7 +124,7 @@ public partial class AssetDomainObject : DomainObject }, ct); case MoveAsset move: - return UpdateReturnAsync(move, async (c, ct) => + return ApplyReturnAsync(move, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -139,7 +142,7 @@ public partial class AssetDomainObject : DomainObject }, ct); case DeleteAsset delete: - return UpdateAsync(delete, async (c, ct) => + return ApplyAsync(delete, async (c, ct) => { var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs index b889c3cc1..481c440a3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs @@ -36,16 +36,24 @@ public sealed partial class AssetFolderDomainObject : DomainObject ExecuteAsync(IAggregateCommand command, @@ -54,7 +62,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject + return ApplyReturnAsync(create, async (c, ct) => { await CreateCore(c, ct); @@ -62,7 +70,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject + return ApplyReturnAsync(move, async (c, ct) => { await MoveCore(c, ct); @@ -70,7 +78,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject + return ApplyReturnAsync(rename, async (c, ct) => { await RenameCore(c); @@ -78,7 +86,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject + return Apply(delete, c => { Delete(c); }, ct); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index 6bcde49ce..f8baa8296 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -23,10 +23,10 @@ public interface IAssetQueryService Task FindByHashAsync(Context context, string hash, string fileName, long fileSize, CancellationToken ct = default); - Task FindAsync(Context context, DomainId id, long version = EtagVersion.Any, + Task FindAsync(Context context, DomainId id, bool allowDeleted = false, long version = EtagVersion.Any, CancellationToken ct = default); - Task FindBySlugAsync(Context context, string slug, + Task FindBySlugAsync(Context context, string slug, bool allowDeleted = false, CancellationToken ct = default); Task FindGlobalAsync(Context context, DomainId id, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs index 2b08564e6..095d36e0c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs @@ -7,7 +7,6 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; -using System.Diagnostics; namespace Squidex.Domain.Apps.Entities.Assets.Queries; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index 219527931..ea39750e0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -99,7 +99,7 @@ public sealed class AssetQueryService : IAssetQueryService } } - public async Task FindBySlugAsync(Context context, string slug, + public async Task FindBySlugAsync(Context context, string slug, bool allowDeleted = false, CancellationToken ct = default) { Guard.NotNull(context); @@ -108,7 +108,7 @@ public sealed class AssetQueryService : IAssetQueryService { activity?.SetTag("slug", slug); - var asset = await FindBySlugCoreAsync(context, slug, ct); + var asset = await FindBySlugCoreAsync(context, slug, allowDeleted, ct); if (asset == null) { @@ -139,7 +139,7 @@ public sealed class AssetQueryService : IAssetQueryService } } - public async Task FindAsync(Context context, DomainId id, long version = EtagVersion.Any, + public async Task FindAsync(Context context, DomainId id, bool allowDeleted = false, long version = EtagVersion.Any, CancellationToken ct = default) { Guard.NotNull(context); @@ -156,7 +156,7 @@ public sealed class AssetQueryService : IAssetQueryService } else { - asset = await FindCoreAsync(context, id, ct); + asset = await FindCoreAsync(context, id, allowDeleted, ct); } if (asset == null) @@ -278,7 +278,7 @@ public sealed class AssetQueryService : IAssetQueryService } } - private async Task FindBySlugCoreAsync(Context context, string slug, + private async Task FindBySlugCoreAsync(Context context, string slug, bool allowDeleted, CancellationToken ct) { using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) @@ -286,7 +286,7 @@ public sealed class AssetQueryService : IAssetQueryService // Enforce a hard timeout combined.CancelAfter(options.TimeoutFind); - return await assetRepository.FindAssetBySlugAsync(context.App.Id, slug, combined.Token); + return await assetRepository.FindAssetBySlugAsync(context.App.Id, slug, allowDeleted, combined.Token); } } @@ -302,7 +302,7 @@ public sealed class AssetQueryService : IAssetQueryService } } - private async Task FindCoreAsync(Context context, DomainId id, + private async Task FindCoreAsync(Context context, DomainId id, bool allowDeleted, CancellationToken ct) { using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) @@ -310,7 +310,7 @@ public sealed class AssetQueryService : IAssetQueryService // Enforce a hard timeout combined.CancelAfter(options.TimeoutFind); - return await assetRepository.FindAssetAsync(context.App.Id, id, combined.Token); + return await assetRepository.FindAssetAsync(context.App.Id, id, allowDeleted, combined.Token); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs index c5f5c603d..c4e006ea2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Text; using Squidex.Domain.Apps.Core; using Squidex.Infrastructure.Json; 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 2912c687a..04523a27f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -26,12 +26,12 @@ public interface IAssetRepository Task FindAssetByHashAsync(DomainId appId, string hash, string fileName, long fileSize, CancellationToken ct = default); - Task FindAssetBySlugAsync(DomainId appId, string slug, + Task FindAssetBySlugAsync(DomainId appId, string slug, bool allowDeleted, CancellationToken ct = default); Task FindAssetAsync(DomainId id, CancellationToken ct = default); - Task FindAssetAsync(DomainId appId, DomainId id, + Task FindAssetAsync(DomainId appId, DomainId id, bool allowDeleted, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs index 122b20490..a30e08029 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs @@ -81,8 +81,8 @@ public partial class ContentDomainObject NewVersion = new ContentVersion(e.Status, newData); + // Implictely cancels any pending update jobs. ScheduleJob = null; - break; } @@ -90,8 +90,8 @@ public partial class ContentDomainObject { NewVersion = null; + // Implictely cancels any pending update jobs. ScheduleJob = null; - break; } @@ -123,14 +123,12 @@ public partial class ContentDomainObject case ContentSchedulingCancelled: { ScheduleJob = null; - break; } case ContentStatusScheduled e: { ScheduleJob = ScheduleJob.Build(e.Status, e.Actor, e.DueTime); - break; } @@ -151,7 +149,6 @@ public partial class ContentDomainObject case ContentDeleted: { IsDeleted = true; - break; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs index f1a1aad74..5f3778965 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs @@ -40,27 +40,29 @@ public partial class ContentDomainObject : DomainObject ExecuteAsync(IAggregateCommand command, @@ -69,7 +71,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(upsertContent, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -95,7 +97,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(createContent, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -114,7 +116,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(validate, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -124,7 +126,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(createDraft, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -134,7 +136,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(deleteDraft, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -144,7 +146,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(patchContent, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -154,7 +156,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(updateContent, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -164,7 +166,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(cancelContentSchedule, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); @@ -174,7 +176,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyReturnAsync(changeContentStatus, async (c, ct) => { try { @@ -213,7 +215,7 @@ public partial class ContentDomainObject : DomainObject + return ApplyAsync(deleteContent, async (c, ct) => { var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs index d4a9b0e82..194fede52 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Text; using Squidex.Domain.Apps.Core; using Squidex.Infrastructure.Json; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs index 752b15d76..51323332e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs @@ -37,16 +37,24 @@ public partial class RuleDomainObject : DomainObject return snapshot.IsDeleted; } - protected override bool CanAcceptCreation(ICommand command) + protected override bool CanAccept(ICommand command) { - return command is RuleCommandBase; + return command is RuleCommand c && c.AppId.Equals(Snapshot.AppId) && c.RuleId.Equals(Snapshot.Id); } - protected override bool CanAccept(ICommand command) + protected override bool CanAccept(ICommand command, DomainObjectState state) { - return command is RuleCommand ruleCommand && - ruleCommand.AppId.Equals(Snapshot.AppId) && - ruleCommand.RuleId.Equals(Snapshot.Id); + switch (state) + { + case DomainObjectState.Undefined: + return command is CreateRule; + case DomainObjectState.Empty: + return command is CreateRule; + case DomainObjectState.Created: + return command is not CreateRule; + default: + return false; + } } public override Task ExecuteAsync(IAggregateCommand command, @@ -55,7 +63,7 @@ public partial class RuleDomainObject : DomainObject switch (command) { case CreateRule createRule: - return CreateReturnAsync(createRule, async (c, ct) => + return ApplyReturnAsync(createRule, async (c, ct) => { await GuardRule.CanCreate(c, AppProvider()); @@ -65,7 +73,7 @@ public partial class RuleDomainObject : DomainObject }, ct); case UpdateRule updateRule: - return UpdateReturnAsync(updateRule, async (c, ct) => + return ApplyReturnAsync(updateRule, async (c, ct) => { await GuardRule.CanUpdate(c, Snapshot, AppProvider()); @@ -75,7 +83,7 @@ public partial class RuleDomainObject : DomainObject }, ct); case EnableRule enable: - return UpdateReturn(enable, c => + return ApplyReturn(enable, c => { Enable(c); @@ -83,7 +91,7 @@ public partial class RuleDomainObject : DomainObject }, ct); case DisableRule disable: - return UpdateReturn(disable, c => + return ApplyReturn(disable, c => { Disable(c); @@ -91,13 +99,13 @@ public partial class RuleDomainObject : DomainObject }, ct); case DeleteRule delete: - return Update(delete, c => + return Apply(delete, c => { Delete(c); }, ct); case TriggerRule triggerRule: - return UpdateReturnAsync(triggerRule, async (c, ct) => + return ApplyReturnAsync(triggerRule, async (c, ct) => { await Trigger(triggerRule); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs index cab04a50c..0ad9b3104 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs @@ -34,16 +34,24 @@ public sealed partial class SchemaDomainObject : DomainObject ExecuteAsync(IAggregateCommand command, @@ -52,7 +60,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(addField, c => { GuardSchemaField.CanAdd(c, Snapshot.SchemaDef); @@ -62,7 +70,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(createSchema, c => { GuardSchema.CanCreate(c); @@ -72,7 +80,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(synchronize, c => { GuardSchema.CanSynchronize(c); @@ -82,7 +90,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(deleteField, c => { GuardSchemaField.CanDelete(deleteField, Snapshot.SchemaDef); @@ -92,7 +100,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(lockField, c => { GuardSchemaField.CanLock(lockField, Snapshot.SchemaDef); @@ -102,7 +110,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(hideField, c => { GuardSchemaField.CanHide(c, Snapshot.SchemaDef); @@ -112,7 +120,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(showField, c => { GuardSchemaField.CanShow(c, Snapshot.SchemaDef); @@ -122,7 +130,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(disableField, c => { GuardSchemaField.CanDisable(c, Snapshot.SchemaDef); @@ -132,7 +140,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(enableField, c => { GuardSchemaField.CanEnable(c, Snapshot.SchemaDef); @@ -142,7 +150,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(updateField, c => { GuardSchemaField.CanUpdate(c, Snapshot.SchemaDef); @@ -152,7 +160,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(reorderFields, c => { GuardSchema.CanReorder(c, Snapshot.SchemaDef); @@ -162,7 +170,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(configureFieldRules, c => { GuardSchema.CanConfigureFieldRules(c); @@ -172,7 +180,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(configurePreviewUrls, c => { GuardSchema.CanConfigurePreviewUrls(c); @@ -182,7 +190,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(configureUIFields, c => { GuardSchema.CanConfigureUIFields(c, Snapshot.SchemaDef); @@ -192,7 +200,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(changeCategory, c => { ChangeCategory(c); @@ -200,7 +208,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(update, c => { Update(c); @@ -208,7 +216,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(publish, c => { Publish(c); @@ -216,7 +224,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(unpublish, c => { Unpublish(c); @@ -224,7 +232,7 @@ public sealed partial class SchemaDomainObject : DomainObject + return ApplyReturn(configureScripts, c => { ConfigureScripts(c); @@ -232,10 +240,7 @@ public sealed partial class SchemaDomainObject : DomainObject - { - Delete(c); - }, ct); + return Apply(deleteSchema, Delete, ct); default: ThrowHelper.NotSupportedException(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs index 451c97264..dd25ce74d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs @@ -39,14 +39,24 @@ public partial class TeamDomainObject : DomainObject return false; } - protected override bool CanAcceptCreation(ICommand command) + protected override bool CanAccept(ICommand command) { - return command is TeamCommandBase; + return command is TeamCommand c && Equals(c.TeamId, Snapshot.Id); } - protected override bool CanAccept(ICommand command) + protected override bool CanAccept(ICommand command, DomainObjectState state) { - return command is TeamCommand update && Equals(update?.TeamId, Snapshot.Id); + switch (state) + { + case DomainObjectState.Undefined: + return command is CreateTeam; + case DomainObjectState.Empty: + return command is CreateTeam; + case DomainObjectState.Created: + return command is not CreateTeam; + default: + return false; + } } public override Task ExecuteAsync(IAggregateCommand command, @@ -55,7 +65,7 @@ public partial class TeamDomainObject : DomainObject switch (command) { case CreateTeam create: - return CreateReturn(create, c => + return ApplyReturn(create, c => { GuardTeam.CanCreate(c); @@ -65,7 +75,7 @@ public partial class TeamDomainObject : DomainObject }, ct); case UpdateTeam update: - return UpdateReturn(update, c => + return ApplyReturn(update, c => { GuardTeam.CanUpdate(c); @@ -75,7 +85,7 @@ public partial class TeamDomainObject : DomainObject }, ct); case AssignContributor assignContributor: - return UpdateReturnAsync(assignContributor, async (c, ct) => + return ApplyReturnAsync(assignContributor, async (c, ct) => { await GuardTeamContributors.CanAssign(c, Snapshot, Users); @@ -85,7 +95,7 @@ public partial class TeamDomainObject : DomainObject }, ct); case RemoveContributor removeContributor: - return UpdateReturn(removeContributor, c => + return ApplyReturn(removeContributor, c => { GuardTeamContributors.CanRemove(c, Snapshot); @@ -95,7 +105,7 @@ public partial class TeamDomainObject : DomainObject }, ct); case ChangePlan changePlan: - return UpdateReturnAsync(changePlan, async (c, ct) => + return ApplyReturnAsync(changePlan, async (c, ct) => { GuardTeam.CanChangePlan(c, BillingPlans); diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs index 37c858a35..63f9ae15a 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs @@ -9,133 +9,40 @@ namespace Squidex.Infrastructure.Commands; public partial class DomainObject { - protected Task CreateReturnAsync(TCommand command, Func> handler, + protected async Task ApplyReturnAsync(TCommand command, Func> handler, CancellationToken ct = default) where TCommand : ICommand { - EnsureCanCreate(command); - - return UpsertCoreAsync(command, handler, true, ct); + return await UpsertCoreAsync(command, handler, ct); } - protected Task CreateReturn(TCommand command, Func handler, + protected async Task ApplyReturn(TCommand command, Func handler, CancellationToken ct = default) where TCommand : ICommand { - return CreateReturnAsync(command, (c, _) => + return await UpsertCoreAsync(command, (c, _) => { var result = handler(c); - return Task.FromResult(result); }, ct); } - protected Task CreateAsync(TCommand command, Func handler, - CancellationToken ct = default) where TCommand : ICommand - { - EnsureCanCreate(command); - - return UpsertCoreAsync(command, async (c, ct) => - { - await handler(c, ct); - - return None.Value; - }, true, ct); - } - - protected Task Create(TCommand command, Action handler, - CancellationToken ct = default) where TCommand : ICommand - { - return CreateAsync(command, (c, ct) => - { - handler(c); - - return Task.FromResult(None.Value); - }, ct); - } - - protected async Task UpdateReturnAsync(TCommand command, Func> handler, + protected async Task ApplyAsync(TCommand command, Func handler, CancellationToken ct = default) where TCommand : ICommand { - await EnsureCanUpdateAsync(command, ct); - - return await UpsertCoreAsync(command, handler, false, ct); - } - - protected Task UpdateReturn(TCommand command, Func handler, - CancellationToken ct = default) where TCommand : ICommand - { - return UpdateReturnAsync(command, (c, ct) => - { - var result = handler(c); - - return Task.FromResult(result); - }, ct); - } - - protected async Task UpdateAsync(TCommand command, Func handler, - CancellationToken ct = default) where TCommand : ICommand - { - await EnsureCanUpdateAsync(command, ct); - return await UpsertCoreAsync(command, async (c, ct) => { await handler(c, ct); - return None.Value; - }, false, ct); - } - - protected async Task Update(TCommand command, Action handler, - CancellationToken ct = default) where TCommand : ICommand - { - return await UpdateAsync(command, (c, _) => - { - handler(c); - - return Task.FromResult(None.Value); }, ct); } - protected async Task UpsertReturnAsync(TCommand command, Func> handler, - CancellationToken ct = default) where TCommand : ICommand - { - await EnsureCanUpsertAsync(command, ct); - - return await UpsertCoreAsync(command, handler, true, ct); - } - - protected async Task UpsertReturn(TCommand command, Func handler, - CancellationToken ct = default) where TCommand : ICommand - { - return await UpsertReturnAsync(command, (c, _) => - { - var result = handler(c); - - return Task.FromResult(result); - }, ct); - } - - protected async Task UpsertAsync(TCommand command, Func handler, - CancellationToken ct = default) where TCommand : ICommand - { - await EnsureCanUpsertAsync(command, ct); - - return await UpsertCoreAsync(command, async (c, ct) => - { - await handler(c, ct); - - return None.Value; - }, true, ct); - } - - protected async Task Upsert(TCommand command, Action handler, + protected async Task Apply(TCommand command, Action handler, CancellationToken ct = default) where TCommand : ICommand { Guard.NotNull(handler); - return await UpsertAsync(command, (c, _) => + return await UpsertCoreAsync(command, (c, _) => { handler(c); - return Task.FromResult(None.Value); }, ct); } @@ -145,8 +52,6 @@ public partial class DomainObject { Guard.NotNull(handler); - await EnsureCanDeleteAsync(command, ct); - return await DeleteCoreAsync(command, async (c, ct) => { await handler(c, ct); @@ -160,115 +65,11 @@ public partial class DomainObject { Guard.NotNull(handler); - return await DeletePermanentAsync(command, (c, _) => + return await DeleteCoreAsync(command, (c, _) => { handler(c); return Task.FromResult(None.Value); }, ct); } - - private void EnsureCanCreate(TCommand command) where TCommand : ICommand - { - Guard.NotNull(command); - - if (Version != EtagVersion.Empty && !(IsDeleted(Snapshot) && CanRecreate())) - { - throw new DomainObjectConflictException(uniqueId.ToString()); - } - - MatchingVersion(command); - MatchingCreateCommand(command); - } - - private async Task EnsureCanUpdateAsync(TCommand command, - CancellationToken ct) where TCommand : ICommand - { - Guard.NotNull(command); - - await EnsureLoadedAsync(ct); - - NotDeleted(); - NotEmpty(); - - MatchingVersion(command); - MatchingCommand(command); - } - - private async Task EnsureCanUpsertAsync(TCommand command, - CancellationToken ct) where TCommand : ICommand - { - Guard.NotNull(command); - - await EnsureLoadedAsync(ct); - - if (IsDeleted(Snapshot) && !CanRecreate()) - { - throw new DomainObjectDeletedException(uniqueId.ToString()); - } - - MatchingVersion(command); - - if (Version <= EtagVersion.Empty) - { - MatchingCreateCommand(command); - } - else - { - MatchingCommand(command); - } - } - - private async Task EnsureCanDeleteAsync(TCommand command, - CancellationToken ct) where TCommand : ICommand - { - Guard.NotNull(command); - - await EnsureLoadedAsync(ct); - - NotEmpty(); - - MatchingVersion(command); - MatchingCommand(command); - } - - private void NotDeleted() - { - if (IsDeleted(Snapshot)) - { - throw new DomainObjectDeletedException(uniqueId.ToString()); - } - } - - private void NotEmpty() - { - if (Version <= EtagVersion.Empty) - { - throw new DomainObjectNotFoundException(uniqueId.ToString()); - } - } - - private void MatchingVersion(TCommand command) where TCommand : ICommand - { - if (Version > EtagVersion.Empty && command.ExpectedVersion > EtagVersion.Any && Version != command.ExpectedVersion) - { - throw new DomainObjectVersionException(uniqueId.ToString(), Version, command.ExpectedVersion); - } - } - - private void MatchingCreateCommand(TCommand command) where TCommand : ICommand - { - if (!CanAcceptCreation(command)) - { - throw new DomainException("Invalid command."); - } - } - - private void MatchingCommand(TCommand command) where TCommand : ICommand - { - if (!CanAccept(command)) - { - throw new DomainException("Invalid command."); - } - } } diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs index 1afb81336..8cffb0319 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs @@ -39,6 +39,29 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom get => snapshot.Version; } + public DomainObjectState ObjectState + { + get + { + if (!isLoaded) + { + return DomainObjectState.Undefined; + } + + if (IsDeleted(Snapshot)) + { + return DomainObjectState.Deleted; + } + + if (Version <= EtagVersion.Empty) + { + return DomainObjectState.Empty; + } + + return DomainObjectState.Created; + } + } + protected DomainObject(DomainId uniqueId, IPersistenceFactory persistenceFactory, ILogger log) { @@ -170,6 +193,8 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom { Guard.NotNull(handler); + await EnsureCommandAsync(command, ct); + var previousSnapshot = Snapshot; var previousVersion = Version; try @@ -207,11 +232,15 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom } } - private async Task UpsertCoreAsync(TCommand command, Func> handler, bool isCreation, + private async Task UpsertCoreAsync(TCommand command, Func> handler, CancellationToken ct) where TCommand : ICommand { Guard.NotNull(handler); + var isDryRun = !isLoaded && CanAccept(command, DomainObjectState.Undefined); + + await EnsureCommandAsync(command, ct); + var previousSnapshot = Snapshot; var previousVersion = Version; try @@ -225,35 +254,29 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom } catch (InconsistentStateException ex) { + if (!isDryRun) + { + throw new DomainObjectVersionException(uniqueId.ToString(), ex.VersionCurrent, ex.VersionExpected, ex); + } + // Start from the previous, unchanged snapshot. snapshot = previousSnapshot; // Create commands do not load the domain object for performance reasons, therefore we ensure it is loaded. await EnsureLoadedAsync(ct); - var isDeleted = IsDeleted(Snapshot); + previousVersion = Version; + previousSnapshot = Snapshot; - if (isDeleted && isCreation && CanRecreate()) - { - foreach (var @event in uncomittedEvents) - { - ApplyEvent(@event, Snapshot, Version, false, true); - } + // Run the validation again, because we could not make some checks before. + EnsureCommand(command); - await WriteAsync(events, ct); - } - else if (isDeleted) - { - throw new DomainObjectDeletedException(uniqueId.ToString()); - } - else if (isCreation) + foreach (var @event in uncomittedEvents) { - throw new DomainObjectConflictException(uniqueId.ToString()); - } - else - { - throw new DomainObjectVersionException(uniqueId.ToString(), ex.VersionCurrent, ex.VersionExpected, ex); + ApplyEvent(@event, Snapshot, Version, false, true); } + + await WriteAsync(events, ct); } isLoaded = true; @@ -271,14 +294,46 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom } } - protected virtual bool CanAcceptCreation(ICommand command) + private async Task EnsureCommandAsync(TCommand command, + CancellationToken ct) where TCommand : ICommand { - return true; + if (!isLoaded && !CanAccept(command, DomainObjectState.Undefined)) + { + await EnsureLoadedAsync(ct); + } + + if (isLoaded) + { + EnsureCommand(command); + } } - protected virtual bool CanAccept(ICommand command) + private void EnsureCommand(TCommand command) where TCommand : ICommand { - return true; + if (ObjectState > DomainObjectState.Empty && !CanAccept(command)) + { + throw new DomainException("Invalid command."); + } + + if (!CanAccept(command, ObjectState)) + { + if (IsDeleted(Snapshot)) + { + throw new DomainObjectDeletedException(uniqueId.ToString()); + } + + if (Version <= EtagVersion.Empty) + { + throw new DomainObjectNotFoundException(uniqueId.ToString()); + } + + throw new DomainObjectConflictException(uniqueId.ToString()); + } + + if (Version > EtagVersion.Empty && command.ExpectedVersion > EtagVersion.Any && Version != command.ExpectedVersion) + { + throw new DomainObjectVersionException(uniqueId.ToString(), Version, command.ExpectedVersion); + } } protected virtual bool IsDeleted(T snapshot) @@ -286,12 +341,17 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom return false; } - protected virtual bool CanRecreate() + protected virtual bool IsRecreation(IEvent @event) + { + return false; + } + + protected virtual bool CanAccept(ICommand command) { return false; } - protected virtual bool CanRecreate(IEvent @event) + protected virtual bool CanAccept(ICommand command, DomainObjectState state) { return false; } @@ -300,7 +360,7 @@ public abstract partial class DomainObject : IAggregate where T : class, IDom { if (IsDeleted(snapshot)) { - if (!CanRecreate(@event.Payload)) + if (!IsRecreation(@event.Payload)) { return default; } diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObjectState.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObjectState.cs new file mode 100644 index 000000000..60abb2384 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObjectState.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Commands; + +public enum DomainObjectState +{ + Undefined, + Empty, + Created, + Deleted +} diff --git a/backend/src/Squidex.Infrastructure/DomainId.cs b/backend/src/Squidex.Infrastructure/DomainId.cs index 42e795d7f..9cf9f3dfa 100644 --- a/backend/src/Squidex.Infrastructure/DomainId.cs +++ b/backend/src/Squidex.Infrastructure/DomainId.cs @@ -55,7 +55,7 @@ public readonly struct DomainId : IEquatable, IComparable public override bool Equals(object? obj) { - return obj is DomainId status && Equals(status); + return obj is DomainId id && Equals(id); } public bool Equals(DomainId other) diff --git a/backend/src/Squidex.Infrastructure/RandomHash.cs b/backend/src/Squidex.Infrastructure/RandomHash.cs index daf512f6d..fcd72546d 100644 --- a/backend/src/Squidex.Infrastructure/RandomHash.cs +++ b/backend/src/Squidex.Infrastructure/RandomHash.cs @@ -5,9 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Security.Cryptography; -using System.Text; - namespace Squidex.Infrastructure; public static class RandomHash diff --git a/backend/src/Squidex.Web/ETagExtensions.cs b/backend/src/Squidex.Web/ETagExtensions.cs index f02d0f612..557f9bdf7 100644 --- a/backend/src/Squidex.Web/ETagExtensions.cs +++ b/backend/src/Squidex.Web/ETagExtensions.cs @@ -7,7 +7,6 @@ using System.Globalization; using System.Security.Cryptography; -using System.Text; using Microsoft.AspNetCore.Http; using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 7475a3393..2867cb83b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -64,11 +64,11 @@ public sealed class AssetContentController : ApiController { var requestContext = Context.Clone(b => b.WithNoAssetEnrichment()); - var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug), ct: HttpContext.RequestAborted); + var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug), request.Deleted, ct: HttpContext.RequestAborted); if (asset == null) { - asset = await assetQuery.FindBySlugAsync(requestContext, idOrSlug, HttpContext.RequestAborted); + asset = await assetQuery.FindBySlugAsync(requestContext, idOrSlug, request.Deleted, HttpContext.RequestAborted); } return await DeliverAssetAsync(requestContext, asset, request); @@ -117,7 +117,7 @@ public sealed class AssetContentController : ApiController { if (context.App != null) { - asset = await assetQuery.FindAsync(context, asset.Id, request.Version, HttpContext.RequestAborted); + asset = await assetQuery.FindAsync(context, asset.Id, false, request.Version, HttpContext.RequestAborted); } else { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs index a3c320469..7b5537c57 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs @@ -93,6 +93,12 @@ public sealed class AssetContentQueryDto [FromQuery(Name = "force")] public bool Force { get; set; } + /// + /// Also return deleted content items. + /// + [FromQuery(Name = "deleted")] + public bool Deleted { get; set; } + /// /// True to force a new resize even if it already stored. /// diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs index 2ca5037f1..d9829b0f7 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs @@ -321,7 +321,7 @@ public class AssetsFluidExtensionTests : GivenContext AppId = AppId }; - A.CallTo(() => assetQuery.FindAsync(A._, assetId, EtagVersion.Any, A._)) + A.CallTo(() => assetQuery.FindAsync(A._, assetId, false, EtagVersion.Any, A._)) .Returns(asset); var vars = new TemplateVars @@ -349,10 +349,10 @@ public class AssetsFluidExtensionTests : GivenContext AppId = AppId }; - A.CallTo(() => assetQuery.FindAsync(A._, assetId1, EtagVersion.Any, A._)) + A.CallTo(() => assetQuery.FindAsync(A._, assetId1, false, EtagVersion.Any, A._)) .Returns(asset1); - A.CallTo(() => assetQuery.FindAsync(A._, assetId2, EtagVersion.Any, A._)) + A.CallTo(() => assetQuery.FindAsync(A._, assetId2, false, EtagVersion.Any, A._)) .Returns(asset2); var vars = new TemplateVars diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs index 1b7235d16..60405b409 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs @@ -48,7 +48,7 @@ public class AssetsQueryIntegrationTests : IClassFixture, IA { var random = _.RandomValue(); - var asset = await _.AssetRepository.FindAssetBySlugAsync(_.RandomAppId(), random); + var asset = await _.AssetRepository.FindAssetBySlugAsync(_.RandomAppId(), random, false); // The Slug is random here, as it does not really matter. Assert.NotNull(asset); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs index e65801e82..274ade7da 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs @@ -45,10 +45,10 @@ public class AssetQueryServiceTests : GivenContext { var asset = CreateAsset(DomainId.NewGuid()); - A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", A._)) + A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", true, A._)) .Returns(asset); - var actual = await sut.FindBySlugAsync(ApiContext, "slug", CancellationToken); + var actual = await sut.FindBySlugAsync(ApiContext, "slug", true, CancellationToken); AssertAsset(asset, actual); } @@ -58,10 +58,10 @@ public class AssetQueryServiceTests : GivenContext { var asset = CreateAsset(DomainId.NewGuid()); - A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", A._)) + A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", false, A._)) .Returns(Task.FromResult(null)); - var actual = await sut.FindBySlugAsync(ApiContext, "slug", CancellationToken); + var actual = await sut.FindBySlugAsync(ApiContext, "slug", false, CancellationToken); Assert.Null(actual); } @@ -71,7 +71,7 @@ public class AssetQueryServiceTests : GivenContext { var asset = CreateAsset(DomainId.NewGuid()); - A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, A._)) + A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, false, A._)) .Returns(asset); var actual = await sut.FindAsync(ApiContext, asset.Id, ct: CancellationToken); @@ -84,7 +84,7 @@ public class AssetQueryServiceTests : GivenContext { var asset = CreateAsset(DomainId.NewGuid()); - A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, A._)) + A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, false, A._)) .Returns(Task.FromResult(null)); var actual = await sut.FindAsync(ApiContext, asset.Id, ct: CancellationToken); @@ -100,7 +100,7 @@ public class AssetQueryServiceTests : GivenContext A.CallTo(() => assetLoader.GetAsync(AppId.Id, asset.Id, 2, A._)) .Returns(asset); - var actual = await sut.FindAsync(ApiContext, asset.Id, 2, CancellationToken); + var actual = await sut.FindAsync(ApiContext, asset.Id, false, 2, CancellationToken); AssertAsset(asset, actual); } @@ -113,7 +113,7 @@ public class AssetQueryServiceTests : GivenContext A.CallTo(() => assetLoader.GetAsync(AppId.Id, asset.Id, 2, A._)) .Returns(Task.FromResult(null)); - var actual = await sut.FindAsync(ApiContext, asset.Id, 2, CancellationToken); + var actual = await sut.FindAsync(ApiContext, asset.Id, false, 2, CancellationToken); Assert.Null(actual); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs index 05be5d7bb..4acb61bf5 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs @@ -24,7 +24,6 @@ public class DomainObjectTests ct = cts.Token; state = new TestState(id); - sut = new MyDomainObject(id, state.PersistenceFactory); } @@ -136,7 +135,7 @@ public class DomainObjectTests [Fact] public async Task Should_throw_exception_if_writing_causes_inconsistent_state_exception() { - sut.Recreate = false; + sut.RecreateCommand = false; SetupCreated(2); @@ -149,7 +148,7 @@ public class DomainObjectTests [Fact] public async Task Should_throw_exception_if_writing_causes_inconsistent_state_exception_and_deleted() { - sut.Recreate = false; + sut.RecreateCommand = false; SetupCreated(2); SetupDeleted(); @@ -163,7 +162,7 @@ public class DomainObjectTests [Fact] public async Task Should_recreate_with_create_command_if_deleted_before() { - sut.Recreate = true; + sut.RecreateCommand = true; sut.RecreateEvent = true; SetupCreated(2); @@ -175,7 +174,7 @@ public class DomainObjectTests var actual = await sut.ExecuteAsync(new CreateAuto { Value = 4 }, ct); A.CallTo(() => state.Persistence.WriteEventsAsync(A>>.That.Matches(x => x.Count == 1), ct)) - .MustHaveHappenedANumberOfTimesMatching(x => x == 3); + .MustHaveHappenedANumberOfTimesMatching(x => x == 2); A.CallTo(() => state.Persistence.ReadAsync(A._, ct)) .MustHaveHappened(); @@ -191,7 +190,7 @@ public class DomainObjectTests [Fact] public async Task Should_throw_exception_if_recreation_with_create_command_is_not_allowed() { - sut.Recreate = false; + sut.RecreateCommand = false; SetupCreated(2); SetupDeleted(); @@ -199,13 +198,13 @@ public class DomainObjectTests A.CallTo(() => state.Persistence.WriteEventsAsync(A>>._, ct)) .Throws(new InconsistentStateException(2, -1)).Once(); - await Assert.ThrowsAsync(() => sut.ExecuteAsync(new CreateAuto(), ct)); + await Assert.ThrowsAsync(() => sut.ExecuteAsync(new CreateAuto(), ct)); } [Fact] public async Task Should_recreate_with_upsert_command_if_deleted_before() { - sut.Recreate = true; + sut.RecreateCommand = true; sut.RecreateEvent = true; SetupCreated(2); @@ -217,7 +216,7 @@ public class DomainObjectTests var actual = await sut.ExecuteAsync(new Upsert { Value = 4 }, ct); A.CallTo(() => state.Persistence.WriteEventsAsync(A>>.That.Matches(x => x.Count == 1), ct)) - .MustHaveHappenedANumberOfTimesMatching(x => x == 3); + .MustHaveHappenedANumberOfTimesMatching(x => x == 2); A.CallTo(() => state.Persistence.ReadAsync(A._, ct)) .MustHaveHappened(); @@ -233,7 +232,7 @@ public class DomainObjectTests [Fact] public async Task Should_throw_exception_if_recreation_with_upsert_command_is_not_allowed() { - sut.Recreate = false; + sut.RecreateCommand = false; SetupCreated(2); SetupDeleted(); @@ -337,10 +336,14 @@ public class DomainObjectTests } [Fact] - public async Task Should_throw_exception_on_create_command_is_rejected_due_to_version_conflict() + public async Task Should_throw_exception_if_create_command_is_rejected_due_to_version_conflict() { A.CallTo(() => state.Persistence.WriteEventsAsync(A>>._, ct)) - .Throws(new InconsistentStateException(4, EtagVersion.Empty)); + .Invokes(() => + { + SetupCreated(4); + throw new InconsistentStateException(4, EtagVersion.Empty); + }); await Assert.ThrowsAsync(() => sut.ExecuteAsync(new CreateAuto(), ct)); } @@ -353,12 +356,6 @@ public class DomainObjectTests await Assert.ThrowsAsync(() => sut.ExecuteAsync(new CreateAuto(), ct)); } - [Fact] - public async Task Should_throw_exception_if_create_command_not_accepted() - { - await Assert.ThrowsAsync(() => sut.ExecuteAsync(new CreateAuto { Value = 99 }, ct)); - } - [Fact] public async Task Should_return_custom_actual_on_create() { @@ -441,14 +438,38 @@ public class DomainObjectTests } [Fact] - public async Task Should_write_events_to_delete_stream_on_delete() + public async Task Should_write_events_to_delete_stream_on_permanent_delete() + { + SetupCreated(4); + + var deleteStream = A.Fake>(); + + A.CallTo(() => state.PersistenceFactory.WithSnapshots(typeof(MyDomainObject), DomainId.Combine(id, DomainId.Create("deleted")), null)) + .Returns(deleteStream); + + await sut.ExecuteAsync(new DeletePermanent(), ct); + + AssertSnapshot(sut.Snapshot, 0, EtagVersion.Empty, false); + + A.CallTo(() => state.Persistence.DeleteAsync(ct)) + .MustHaveHappened(); + + A.CallTo(() => state.Persistence.WriteEventsAsync(A>>._, ct)) + .MustNotHaveHappened(); + + A.CallTo(() => deleteStream.WriteEventsAsync(A>>._, A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_write_events_to_delete_stream_on_permanent_delete_if_already_deleted() { SetupCreated(4); SetupDeleted(); var deleteStream = A.Fake>(); - A.CallTo(() => state.PersistenceFactory.WithEventSourcing(typeof(MyDomainObject), DomainId.Combine(id, DomainId.Create("deleted")), null)) + A.CallTo(() => state.PersistenceFactory.WithSnapshots(typeof(MyDomainObject), DomainId.Combine(id, DomainId.Create("deleted")), null)) .Returns(deleteStream); await sut.ExecuteAsync(new DeletePermanent(), ct); @@ -459,7 +480,7 @@ public class DomainObjectTests .MustHaveHappened(); A.CallTo(() => state.Persistence.WriteEventsAsync(A>>._, ct)) - .MustHaveHappenedOnceExactly(); + .MustNotHaveHappened(); A.CallTo(() => deleteStream.WriteEventsAsync(A>>._, A._)) .MustNotHaveHappened(); @@ -494,7 +515,7 @@ public class DomainObjectTests private void SetupDeleted() { - sut.ExecuteAsync(new Delete(), ct).Wait(ct); + state.AddEvent(new Deleted()); } private void SetupCreated(int value) diff --git a/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs b/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs index 65be0c115..22cd6a2d7 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs @@ -16,7 +16,7 @@ namespace Squidex.Infrastructure.TestHelpers; public sealed class MyDomainObject : DomainObject { - public bool Recreate { get; set; } + public bool RecreateCommand { get; set; } public bool RecreateEvent { get; set; } @@ -25,28 +25,23 @@ public sealed class MyDomainObject : DomainObject { } - protected override bool CanRecreate(IEvent @event) + protected override bool IsRecreation(IEvent @event) { return RecreateEvent && @event is ValueChanged; } - protected override bool CanRecreate() + protected override bool IsDeleted(MyDomainState snapshot) { - return Recreate; + return snapshot.IsDeleted; } - protected override bool CanAcceptCreation(ICommand command) + protected override bool CanAccept(ICommand command) { - if (command is CreateAuto update) + if (command is CreateAuto create) { - return update.Value != 99; + return create.Value != 99; } - return true; - } - - protected override bool CanAccept(ICommand command) - { if (command is UpdateAuto update) { return update.Value != 99; @@ -55,9 +50,36 @@ public sealed class MyDomainObject : DomainObject return true; } - protected override bool IsDeleted(MyDomainState snapshot) + protected override bool CanAccept(ICommand command, DomainObjectState state) { - return snapshot.IsDeleted; + static bool CanCreate(ICommand command) + { + return + command is CreateAuto || + command is CreateCustom || + command is Upsert; + } + + static bool CanUpdate(ICommand command) + { + return + command is UpdateAuto || + command is UpdateCustom || + command is Delete || + command is DeletePermanent; + } + + switch (state) + { + case DomainObjectState.Undefined: + return CanCreate(command); + case DomainObjectState.Empty: + return CanCreate(command); + case DomainObjectState.Deleted: + return (CanCreate(command) && RecreateCommand) || command is DeletePermanent; + default: + return CanUpdate(command); + } } public override Task ExecuteAsync(IAggregateCommand command, @@ -66,19 +88,19 @@ public sealed class MyDomainObject : DomainObject switch (command) { case Upsert c: - return Upsert(c, createAuto => + return Apply(c, createAuto => { RaiseEvent(new ValueChanged { Value = createAuto.Value }); }, ct); case CreateAuto c: - return Create(c, createAuto => + return Apply(c, createAuto => { RaiseEvent(new ValueChanged { Value = createAuto.Value }); }, ct); case CreateCustom c: - return CreateReturn(c, createCustom => + return ApplyReturn(c, createCustom => { RaiseEvent(new ValueChanged { Value = createCustom.Value }); @@ -86,13 +108,13 @@ public sealed class MyDomainObject : DomainObject }, ct); case UpdateAuto c: - return Update(c, updateAuto => + return Apply(c, updateAuto => { RaiseEvent(new ValueChanged { Value = updateAuto.Value }); }, ct); case UpdateCustom c: - return UpdateReturn(c, updateCustom => + return ApplyReturn(c, updateCustom => { RaiseEvent(new ValueChanged { Value = updateCustom.Value }); @@ -100,7 +122,7 @@ public sealed class MyDomainObject : DomainObject }, ct); case Delete c: - return Update(c, delete => + return Apply(c, delete => { RaiseEvent(new Deleted()); }, ct); diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index 08c140075..8457d5621 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -6,9 +6,9 @@ // ========================================================================== using System.Net; -using Squidex.Assets; using Squidex.ClientLibrary; using TestSuite.Fixtures; +using TestSuite.Utils; #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable SA1507 // Code should not contain multiple blank lines in a row @@ -78,7 +78,7 @@ public class AssetTests : IClassFixture var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); - await UploadInChunksAsync(fileParameter); + await _.Client.Assets.UploadInChunksAsync(progress, fileParameter); Assert.Null(progress.Exception); Assert.NotEmpty(progress.Progress); @@ -219,7 +219,7 @@ public class AssetTests : IClassFixture var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); - await UploadInChunksAsync(fileParameter, asset_1.Id); + await _.Client.Assets.UploadInChunksAsync(progress, fileParameter, asset_1.Id); Assert.Null(progress.Exception); Assert.NotEmpty(progress.Progress); @@ -738,146 +738,48 @@ public class AssetTests : IClassFixture Assert.NotEqual(asset_1.FileSize, asset_2.FileSize); } - private async Task UploadInChunksAsync(FileParameter fileParameter, string id = null) - { - var pausingStream = new PauseStream(fileParameter.Data, 0.25); - var pausingFile = new FileParameter(pausingStream, fileParameter.FileName, fileParameter.ContentType) - { - ContentLength = fileParameter.Data.Length - }; - - await using (pausingFile.Data) - { - using var cts = new CancellationTokenSource(5000); - - while (progress.Asset == null && progress.Exception == null && !cts.IsCancellationRequested) - { - pausingStream.Reset(); - - await _.Client.Assets.UploadAssetAsync(pausingFile, progress.AsOptions(id), cts.Token); - progress.Uploaded(); - } - } - } - - public class ProgressHandler : IAssetProgressHandler + [Fact] + public async Task Should_recover_deleted_asset() { - public string FileId { get; private set; } = Guid.NewGuid().ToString(); - - public List Progress { get; } = new List(); - - public List Uploads { get; } = new List(); - - public Exception Exception { get; private set; } - - public AssetDto Asset { get; private set; } - - public AssetUploadOptions AsOptions(string id = null) - { - var options = default(AssetUploadOptions); - options.ProgressHandler = this; - options.FileId = FileId; - options.Id = id; - - return options; - } - - public void Uploaded() - { - Uploads.Add(Progress.LastOrDefault()); - } - - public Task OnCompletedAsync(AssetUploadCompletedEvent @event, - CancellationToken ct) - { - Asset = @event.Asset; - return Task.CompletedTask; - } - - public Task OnCreatedAsync(AssetUploadCreatedEvent @event, - CancellationToken ct) - { - FileId = @event.FileId; - return Task.CompletedTask; - } + // STEP 0: Create app. + var (client, _) = await _.PostAppAsync(); - public Task OnProgressAsync(AssetUploadProgressEvent @event, - CancellationToken ct) - { - Progress.Add(@event.Progress); - return Task.CompletedTask; - } - public Task OnFailedAsync(AssetUploadExceptionEvent @event, - CancellationToken ct) - { - Exception = @event.Exception; - return Task.CompletedTask; - } - } + // STEP 1: Create asset. + var asset_1 = await client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png"); - public class PauseStream : DelegateStream - { - private readonly int maxLength; - private long totalRead; - private long totalRemaining; - private long seekStart; - public override long Length - { - get => Math.Min(maxLength, totalRemaining); - } + // STEP 2: Delete asset. + await client.Assets.DeleteAssetAsync(asset_1.Id); - public override long Position - { - get => base.Position - seekStart; - set => throw new NotSupportedException(); - } - public PauseStream(Stream innerStream, double pauseAfter) - : base(innerStream) + // STEP 3: Query and recreate asset. + var assets = await client.Assets.GetAssetsAsync(new AssetQuery { - maxLength = (int)Math.Floor(innerStream.Length * pauseAfter) + 1; + Query = new + { + filter = new + { + path = "isDeleted", + op = "eq", + value = true, + } + } + }); - totalRemaining = innerStream.Length; - } + Assert.NotEmpty(assets.Items); - public override long Seek(long offset, SeekOrigin origin) + foreach (var asset in assets.Items) { - var position = seekStart = base.Seek(offset, origin); - - totalRemaining = base.Length - position; - - return position; - } + var content = await client.Assets.GetAssetContentBySlugAsync(asset.Id, string.Empty, deleted: true); - public void Reset() - { - totalRead = 0; + await client.Assets.PostAssetAsync(id: asset.Id, file: new FileParameter(content.Stream, asset.FileName, asset.MimeType)); } - public override async ValueTask ReadAsync(Memory buffer, - CancellationToken cancellationToken = default) - { - var remaining = Length - totalRead; - if (remaining <= 0) - { - return 0; - } + // STEP 4: Query recreated asset. + var asset_2 = await client.Assets.GetAssetAsync(asset_1.Id); - if (remaining < buffer.Length) - { - var remainingBytes = (int)remaining; - - buffer = buffer[..remainingBytes]; - } - - var bytesRead = await base.ReadAsync(buffer, cancellationToken); - - totalRead += bytesRead; - - return bytesRead; - } + Assert.NotNull(asset_2); } } diff --git a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index 6e1c2c1d4..a5c36381d 100644 --- a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -25,7 +25,7 @@ - + diff --git a/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs b/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs index 20ad2d830..ff1690bc7 100644 --- a/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs +++ b/tools/TestSuite/TestSuite.Shared/ClientExtensions.cs @@ -7,6 +7,7 @@ using Squidex.ClientLibrary; using TestSuite.Fixtures; +using TestSuite.Utils; namespace TestSuite; @@ -309,7 +310,29 @@ public static class ClientExtensions return temp; } - public static async Task UploadFileAsync(this IAssetsClient assetsClients, string path, AssetDto asset, string fileName = null) + public static async Task UploadInChunksAsync(this IAssetsClient client, ProgressHandler progress, FileParameter fileParameter, string id = null) + { + var pausingStream = new PauseStream(fileParameter.Data, 0.25); + var pausingFile = new FileParameter(pausingStream, fileParameter.FileName, fileParameter.ContentType) + { + ContentLength = fileParameter.Data.Length + }; + + await using (pausingFile.Data) + { + using var cts = new CancellationTokenSource(5000); + + while (progress.Asset == null && progress.Exception == null && !cts.IsCancellationRequested) + { + pausingStream.Reset(); + + await client.UploadAssetAsync(pausingFile, progress.AsOptions(id), cts.Token); + progress.Uploaded(); + } + } + } + + public static async Task UploadFileAsync(this IAssetsClient client, string path, AssetDto asset, string fileName = null) { var fileInfo = new FileInfo(path); @@ -317,11 +340,11 @@ public static class ClientExtensions { var upload = new FileParameter(stream, fileName ?? fileInfo.Name, asset.MimeType); - return await assetsClients.PutAssetContentAsync(asset.Id, upload); + return await client.PutAssetContentAsync(asset.Id, upload); } } - public static async Task UploadFileAsync(this IAssetsClient assetsClients, string path, string fileType, string fileName = null, string parentId = null, string id = null) + public static async Task UploadFileAsync(this IAssetsClient client, string path, string fileType, string fileName = null, string parentId = null, string id = null) { var fileInfo = new FileInfo(path); @@ -329,11 +352,11 @@ public static class ClientExtensions { var upload = new FileParameter(stream, fileName ?? fileInfo.Name, fileType); - return await assetsClients.PostAssetAsync(parentId, id, true, upload); + return await client.PostAssetAsync(parentId, id, true, upload); } } - public static async Task UpdateFileAsync(this IAssetsClient assetsClients, string id, string path, string fileType, string fileName = null) + public static async Task UpdateFileAsync(this IAssetsClient client, string id, string path, string fileType, string fileName = null) { var fileInfo = new FileInfo(path); @@ -341,17 +364,17 @@ public static class ClientExtensions { var upload = new FileParameter(stream, fileName ?? fileInfo.Name, fileType); - return await assetsClients.PutAssetContentAsync(id, upload); + return await client.PutAssetContentAsync(id, upload); } } - public static async Task UploadRandomFileAsync(this IAssetsClient assetsClients, int size, string parentId = null, string id = null) + public static async Task UploadRandomFileAsync(this IAssetsClient client, int size, string parentId = null, string id = null) { using (var stream = RandomAsset(size)) { var upload = new FileParameter(stream, RandomName(".txt"), "text/csv"); - return await assetsClients.PostAssetAsync(parentId, id, true, upload); + return await client.PostAssetAsync(parentId, id, true, upload); } } diff --git a/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index 62548a879..221f7998a 100644 --- a/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -16,8 +16,9 @@ - - + + + diff --git a/tools/TestSuite/TestSuite.Shared/Utils/PauseStream.cs b/tools/TestSuite/TestSuite.Shared/Utils/PauseStream.cs new file mode 100644 index 000000000..d7ac0b9d8 --- /dev/null +++ b/tools/TestSuite/TestSuite.Shared/Utils/PauseStream.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Assets; + +namespace TestSuite.Utils; + +public sealed class PauseStream : DelegateStream +{ + private readonly int maxLength; + private long totalRead; + private long totalRemaining; + private long seekStart; + + public override long Length + { + get => Math.Min(maxLength, totalRemaining); + } + + public override long Position + { + get => base.Position - seekStart; + set => throw new NotSupportedException(); + } + + public PauseStream(Stream innerStream, double pauseAfter) + : base(innerStream) + { + maxLength = (int)Math.Floor(innerStream.Length * pauseAfter) + 1; + + totalRemaining = innerStream.Length; + } + + public override long Seek(long offset, SeekOrigin origin) + { + var position = seekStart = base.Seek(offset, origin); + + totalRemaining = base.Length - position; + + return position; + } + + public void Reset() + { + totalRead = 0; + } + + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + var remaining = Length - totalRead; + + if (remaining <= 0) + { + return 0; + } + + if (remaining < buffer.Length) + { + var remainingBytes = (int)remaining; + + buffer = buffer[..remainingBytes]; + } + + var bytesRead = await base.ReadAsync(buffer, cancellationToken); + + totalRead += bytesRead; + + return bytesRead; + } +} diff --git a/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs b/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs new file mode 100644 index 000000000..68acd5ce5 --- /dev/null +++ b/tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.ClientLibrary; + +namespace TestSuite.Utils; + +public sealed class ProgressHandler : IAssetProgressHandler +{ + public string FileId { get; private set; } = Guid.NewGuid().ToString(); + + public List Progress { get; } = new List(); + + public List Uploads { get; } = new List(); + + public Exception Exception { get; private set; } + + public AssetDto Asset { get; private set; } + + public AssetUploadOptions AsOptions(string id = null) + { + var options = default(AssetUploadOptions); + options.ProgressHandler = this; + options.FileId = FileId; + options.Id = id; + + return options; + } + + public void Uploaded() + { + Uploads.Add(Progress.LastOrDefault()); + } + + public Task OnCompletedAsync(AssetUploadCompletedEvent @event, + CancellationToken ct) + { + Asset = @event.Asset; + return Task.CompletedTask; + } + + public Task OnCreatedAsync(AssetUploadCreatedEvent @event, + CancellationToken ct) + { + FileId = @event.FileId; + return Task.CompletedTask; + } + + public Task OnProgressAsync(AssetUploadProgressEvent @event, + CancellationToken ct) + { + Progress.Add(@event.Progress); + return Task.CompletedTask; + } + + public Task OnFailedAsync(AssetUploadExceptionEvent @event, + CancellationToken ct) + { + Exception = @event.Exception; + return Task.CompletedTask; + } +}