Browse Source

Domain objects refactoring (#1012)

* Refactor domain objects and add deleted flag to asset content.

* Fix command flow.

* Fix archive.
pull/1016/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
f16ea0b7bd
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs
  2. 34
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  3. 69
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
  4. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  5. 39
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
  6. 28
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
  7. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  8. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs
  9. 16
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  10. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  12. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs
  13. 50
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
  14. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs
  15. 32
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  16. 63
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs
  17. 28
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs
  18. 215
      backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs
  19. 114
      backend/src/Squidex.Infrastructure/Commands/DomainObject.cs
  20. 16
      backend/src/Squidex.Infrastructure/Commands/DomainObjectState.cs
  21. 2
      backend/src/Squidex.Infrastructure/DomainId.cs
  22. 3
      backend/src/Squidex.Infrastructure/RandomHash.cs
  23. 1
      backend/src/Squidex.Web/ETagExtensions.cs
  24. 6
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  25. 6
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs
  26. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs
  27. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs
  28. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs
  29. 65
      backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs
  30. 62
      backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs
  31. 158
      tools/TestSuite/TestSuite.ApiTests/AssetTests.cs
  32. 2
      tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  33. 39
      tools/TestSuite/TestSuite.Shared/ClientExtensions.cs
  34. 5
      tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
  35. 75
      tools/TestSuite/TestSuite.Shared/Utils/PauseStream.cs
  36. 66
      tools/TestSuite/TestSuite.Shared/Utils/ProgressHandler.cs

1
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

34
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -206,28 +206,26 @@ public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAsse
}
}
public async Task<IAssetEntity?> FindAssetBySlugAsync(DomainId appId, string slug,
public async Task<IAssetEntity?> 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<IAssetEntity?> FindAssetAsync(DomainId appId, DomainId id,
public async Task<IAssetEntity?> 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<MongoAsse
Filter.Ne(x => x.IsDeleted, true));
}
private static FilterDefinition<MongoAssetEntity> 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<MongoAssetEntity> 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<MongoAssetEntity> BuildFilter(DomainId appId, DomainId parentId)
{
return Filter.And(

69
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs

@ -40,14 +40,29 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
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<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -56,7 +71,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
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<AppDomainObject.State>
}, ct);
case UpdateApp update:
return UpdateReturn(update, c =>
return ApplyReturn(update, c =>
{
GuardApp.CanUpdate(c);
@ -76,7 +91,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, ct);
case UpdateAppSettings updateSettings:
return UpdateReturn(updateSettings, c =>
return ApplyReturn(updateSettings, c =>
{
GuardApp.CanUpdateSettings(c);
@ -96,7 +111,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, ct);
case UploadAppImage uploadImage:
return UpdateReturn(uploadImage, c =>
return ApplyReturn(uploadImage, c =>
{
GuardApp.CanUploadImage(c);
@ -106,7 +121,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, ct);
case RemoveAppImage removeImage:
return UpdateReturn(removeImage, c =>
return ApplyReturn(removeImage, c =>
{
GuardApp.CanRemoveImage(c);
@ -116,7 +131,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, ct);
case ConfigureAssetScripts configureAssetScripts:
return UpdateReturn(configureAssetScripts, c =>
return ApplyReturn(configureAssetScripts, c =>
{
GuardApp.CanUpdateAssetScripts(c);
@ -126,7 +141,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, ct);
case AddWorkflow addWorkflow:
return UpdateReturn(addWorkflow, c =>
return ApplyReturn(addWorkflow, c =>
{
GuardAppWorkflows.CanAdd(c);
@ -188,7 +203,7 @@ public partial class AppDomainObject : DomainObject<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, 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<AppDomainObject.State>
}, ct);
case ChangePlan changePlan:
return UpdateReturnAsync(changePlan, async (c, ct) =>
return ApplyReturnAsync(changePlan, async (c, ct) =>
{
GuardApp.CanChangePlan(c, Snapshot, BillingPlans);

4
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -142,10 +142,6 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<Asset
}
catch (AssetAlreadyExistsException)
{
if (context.Command is not UpsertAsset)
{
throw;
}
}
}

39
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs

@ -36,26 +36,29 @@ public partial class AssetDomainObject : DomainObject<AssetDomainObject.State>
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<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -64,7 +67,7 @@ public partial class AssetDomainObject : DomainObject<AssetDomainObject.State>
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<AssetDomainObject.State>
}, 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<AssetDomainObject.State>
}, 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<AssetDomainObject.State>
}, 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<AssetDomainObject.State>
}, 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<AssetDomainObject.State>
}, ct);
case DeleteAsset delete:
return UpdateAsync(delete, async (c, ct) =>
return ApplyAsync(delete, async (c, ct) =>
{
var operation = await AssetOperation.CreateAsync(serviceProvider, c, () => Snapshot);

28
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs

@ -36,16 +36,24 @@ public sealed partial class AssetFolderDomainObject : DomainObject<AssetFolderDo
return Snapshot.IsDeleted;
}
protected override bool CanAcceptCreation(ICommand command)
protected override bool CanAccept(ICommand command)
{
return command is AssetFolderCommandBase;
return command is AssetFolderCommand c && c.AppId == Snapshot.AppId && c.AssetFolderId == Snapshot.Id;
}
protected override bool CanAccept(ICommand command)
protected override bool CanAccept(ICommand command, DomainObjectState state)
{
return command is AssetFolderCommand assetFolderCommand &&
Equals(assetFolderCommand.AppId, Snapshot.AppId) &&
Equals(assetFolderCommand.AssetFolderId, Snapshot.Id);
switch (state)
{
case DomainObjectState.Undefined:
return command is CreateAssetFolder;
case DomainObjectState.Empty:
return command is CreateAssetFolder;
case DomainObjectState.Created:
return command is not CreateAssetFolder;
default:
return false;
}
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -54,7 +62,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject<AssetFolderDo
switch (command)
{
case CreateAssetFolder create:
return CreateReturnAsync(create, async (c, ct) =>
return ApplyReturnAsync(create, async (c, ct) =>
{
await CreateCore(c, ct);
@ -62,7 +70,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject<AssetFolderDo
}, ct);
case MoveAssetFolder move:
return UpdateReturnAsync(move, async (c, ct) =>
return ApplyReturnAsync(move, async (c, ct) =>
{
await MoveCore(c, ct);
@ -70,7 +78,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject<AssetFolderDo
}, ct);
case RenameAssetFolder rename:
return UpdateReturnAsync(rename, async (c, ct) =>
return ApplyReturnAsync(rename, async (c, ct) =>
{
await RenameCore(c);
@ -78,7 +86,7 @@ public sealed partial class AssetFolderDomainObject : DomainObject<AssetFolderDo
}, ct);
case DeleteAssetFolder delete:
return Update(delete, c =>
return Apply(delete, c =>
{
Delete(c);
}, ct);

4
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -23,10 +23,10 @@ public interface IAssetQueryService
Task<IEnrichedAssetEntity?> FindByHashAsync(Context context, string hash, string fileName, long fileSize,
CancellationToken ct = default);
Task<IEnrichedAssetEntity?> FindAsync(Context context, DomainId id, long version = EtagVersion.Any,
Task<IEnrichedAssetEntity?> FindAsync(Context context, DomainId id, bool allowDeleted = false, long version = EtagVersion.Any,
CancellationToken ct = default);
Task<IEnrichedAssetEntity?> FindBySlugAsync(Context context, string slug,
Task<IEnrichedAssetEntity?> FindBySlugAsync(Context context, string slug, bool allowDeleted = false,
CancellationToken ct = default);
Task<IEnrichedAssetEntity?> FindGlobalAsync(Context context, DomainId id,

1
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;

16
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs

@ -99,7 +99,7 @@ public sealed class AssetQueryService : IAssetQueryService
}
}
public async Task<IEnrichedAssetEntity?> FindBySlugAsync(Context context, string slug,
public async Task<IEnrichedAssetEntity?> 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<IEnrichedAssetEntity?> FindAsync(Context context, DomainId id, long version = EtagVersion.Any,
public async Task<IEnrichedAssetEntity?> 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<IAssetEntity?> FindBySlugCoreAsync(Context context, string slug,
private async Task<IAssetEntity?> 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<IAssetEntity?> FindCoreAsync(Context context, DomainId id,
private async Task<IAssetEntity?> 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);
}
}
}

1
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;

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -26,12 +26,12 @@ public interface IAssetRepository
Task<IAssetEntity?> FindAssetByHashAsync(DomainId appId, string hash, string fileName, long fileSize,
CancellationToken ct = default);
Task<IAssetEntity?> FindAssetBySlugAsync(DomainId appId, string slug,
Task<IAssetEntity?> FindAssetBySlugAsync(DomainId appId, string slug, bool allowDeleted,
CancellationToken ct = default);
Task<IAssetEntity?> FindAssetAsync(DomainId id,
CancellationToken ct = default);
Task<IAssetEntity?> FindAssetAsync(DomainId appId, DomainId id,
Task<IAssetEntity?> FindAssetAsync(DomainId appId, DomainId id, bool allowDeleted,
CancellationToken ct = default);
}

7
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;
}
}

50
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs

@ -40,27 +40,29 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
return snapshot.IsDeleted;
}
protected override bool CanAcceptCreation(ICommand command)
protected override bool IsRecreation(IEvent @event)
{
return command is ContentCommandBase;
}
protected override bool CanRecreate()
{
return true;
return @event is ContentCreated;
}
protected override bool CanRecreate(IEvent @event)
protected override bool CanAccept(ICommand command)
{
return @event is ContentCreated;
return command is ContentCommand c && c.AppId == Snapshot.AppId && c.SchemaId == Snapshot.SchemaId && c.ContentId == Snapshot.Id;
}
protected override bool CanAccept(ICommand command)
protected override bool CanAccept(ICommand command, DomainObjectState state)
{
return command is ContentCommand contentCommand &&
Equals(contentCommand.AppId, Snapshot.AppId) &&
Equals(contentCommand.SchemaId, Snapshot.SchemaId) &&
Equals(contentCommand.ContentId, Snapshot.Id);
switch (state)
{
case DomainObjectState.Undefined:
return command is CreateContent;
case DomainObjectState.Deleted:
return command is CreateContent or UpsertContent or DeleteContent { Permanent: true };
case DomainObjectState.Empty:
return command is CreateContent or UpsertContent;
default:
return command is not CreateContent;
}
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -69,7 +71,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
switch (command)
{
case UpsertContent upsertContent:
return UpsertReturnAsync(upsertContent, async (c, ct) =>
return ApplyReturnAsync(upsertContent, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -95,7 +97,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case CreateContent createContent:
return CreateReturnAsync(createContent, async (c, ct) =>
return ApplyReturnAsync(createContent, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -114,7 +116,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case ValidateContent validate:
return UpdateReturnAsync(validate, async (c, ct) =>
return ApplyReturnAsync(validate, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -124,7 +126,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case CreateContentDraft createDraft:
return UpdateReturnAsync(createDraft, async (c, ct) =>
return ApplyReturnAsync(createDraft, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -134,7 +136,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case DeleteContentDraft deleteDraft:
return UpdateReturnAsync(deleteDraft, async (c, ct) =>
return ApplyReturnAsync(deleteDraft, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -144,7 +146,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async (c, ct) =>
return ApplyReturnAsync(patchContent, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -154,7 +156,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async (c, ct) =>
return ApplyReturnAsync(updateContent, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -164,7 +166,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case CancelContentSchedule cancelContentSchedule:
return UpdateReturnAsync(cancelContentSchedule, async (c, ct) =>
return ApplyReturnAsync(cancelContentSchedule, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
@ -174,7 +176,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case ChangeContentStatus changeContentStatus:
return UpdateReturnAsync(changeContentStatus, async (c, ct) =>
return ApplyReturnAsync(changeContentStatus, async (c, ct) =>
{
try
{
@ -213,7 +215,7 @@ public partial class ContentDomainObject : DomainObject<ContentDomainObject.Stat
}, ct);
case DeleteContent deleteContent:
return UpdateAsync(deleteContent, async (c, ct) =>
return ApplyAsync(deleteContent, async (c, ct) =>
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);

1
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;

32
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs

@ -37,16 +37,24 @@ public partial class RuleDomainObject : DomainObject<RuleDomainObject.State>
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<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -55,7 +63,7 @@ public partial class RuleDomainObject : DomainObject<RuleDomainObject.State>
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<RuleDomainObject.State>
}, 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<RuleDomainObject.State>
}, ct);
case EnableRule enable:
return UpdateReturn(enable, c =>
return ApplyReturn(enable, c =>
{
Enable(c);
@ -83,7 +91,7 @@ public partial class RuleDomainObject : DomainObject<RuleDomainObject.State>
}, ct);
case DisableRule disable:
return UpdateReturn(disable, c =>
return ApplyReturn(disable, c =>
{
Disable(c);
@ -91,13 +99,13 @@ public partial class RuleDomainObject : DomainObject<RuleDomainObject.State>
}, 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);

63
backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs

@ -34,16 +34,24 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
return snapshot.IsDeleted;
}
protected override bool CanAcceptCreation(ICommand command)
protected override bool CanAccept(ICommand command)
{
return command is SchemaCommandBase;
return command is SchemaCommand c && Equals(c.AppId, Snapshot.AppId) && Equals(c.SchemaId?.Id, Snapshot.Id);
}
protected override bool CanAccept(ICommand command)
protected override bool CanAccept(ICommand command, DomainObjectState state)
{
return command is SchemaCommand schemaCommand &&
Equals(schemaCommand.AppId, Snapshot.AppId) &&
Equals(schemaCommand.SchemaId?.Id, Snapshot.Id);
switch (state)
{
case DomainObjectState.Undefined:
return command is CreateSchema;
case DomainObjectState.Empty:
return command is CreateSchema;
case DomainObjectState.Created:
return command is not CreateSchema;
default:
return false;
}
}
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -52,7 +60,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
switch (command)
{
case AddField addField:
return UpdateReturn(addField, c =>
return ApplyReturn(addField, c =>
{
GuardSchemaField.CanAdd(c, Snapshot.SchemaDef);
@ -62,7 +70,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case CreateSchema createSchema:
return CreateReturn(createSchema, c =>
return ApplyReturn(createSchema, c =>
{
GuardSchema.CanCreate(c);
@ -72,7 +80,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case SynchronizeSchema synchronize:
return UpdateReturn(synchronize, c =>
return ApplyReturn(synchronize, c =>
{
GuardSchema.CanSynchronize(c);
@ -82,7 +90,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case DeleteField deleteField:
return UpdateReturn(deleteField, c =>
return ApplyReturn(deleteField, c =>
{
GuardSchemaField.CanDelete(deleteField, Snapshot.SchemaDef);
@ -92,7 +100,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case LockField lockField:
return UpdateReturn(lockField, c =>
return ApplyReturn(lockField, c =>
{
GuardSchemaField.CanLock(lockField, Snapshot.SchemaDef);
@ -102,7 +110,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case HideField hideField:
return UpdateReturn(hideField, c =>
return ApplyReturn(hideField, c =>
{
GuardSchemaField.CanHide(c, Snapshot.SchemaDef);
@ -112,7 +120,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ShowField showField:
return UpdateReturn(showField, c =>
return ApplyReturn(showField, c =>
{
GuardSchemaField.CanShow(c, Snapshot.SchemaDef);
@ -122,7 +130,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case DisableField disableField:
return UpdateReturn(disableField, c =>
return ApplyReturn(disableField, c =>
{
GuardSchemaField.CanDisable(c, Snapshot.SchemaDef);
@ -132,7 +140,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case EnableField enableField:
return UpdateReturn(enableField, c =>
return ApplyReturn(enableField, c =>
{
GuardSchemaField.CanEnable(c, Snapshot.SchemaDef);
@ -142,7 +150,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case UpdateField updateField:
return UpdateReturn(updateField, c =>
return ApplyReturn(updateField, c =>
{
GuardSchemaField.CanUpdate(c, Snapshot.SchemaDef);
@ -152,7 +160,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ReorderFields reorderFields:
return UpdateReturn(reorderFields, c =>
return ApplyReturn(reorderFields, c =>
{
GuardSchema.CanReorder(c, Snapshot.SchemaDef);
@ -162,7 +170,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ConfigureFieldRules configureFieldRules:
return UpdateReturn(configureFieldRules, c =>
return ApplyReturn(configureFieldRules, c =>
{
GuardSchema.CanConfigureFieldRules(c);
@ -172,7 +180,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ConfigurePreviewUrls configurePreviewUrls:
return UpdateReturn(configurePreviewUrls, c =>
return ApplyReturn(configurePreviewUrls, c =>
{
GuardSchema.CanConfigurePreviewUrls(c);
@ -182,7 +190,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ConfigureUIFields configureUIFields:
return UpdateReturn(configureUIFields, c =>
return ApplyReturn(configureUIFields, c =>
{
GuardSchema.CanConfigureUIFields(c, Snapshot.SchemaDef);
@ -192,7 +200,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ChangeCategory changeCategory:
return UpdateReturn(changeCategory, c =>
return ApplyReturn(changeCategory, c =>
{
ChangeCategory(c);
@ -200,7 +208,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case UpdateSchema update:
return UpdateReturn(update, c =>
return ApplyReturn(update, c =>
{
Update(c);
@ -208,7 +216,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case PublishSchema publish:
return UpdateReturn(publish, c =>
return ApplyReturn(publish, c =>
{
Publish(c);
@ -216,7 +224,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case UnpublishSchema unpublish:
return UpdateReturn(unpublish, c =>
return ApplyReturn(unpublish, c =>
{
Unpublish(c);
@ -224,7 +232,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case ConfigureScripts configureScripts:
return UpdateReturn(configureScripts, c =>
return ApplyReturn(configureScripts, c =>
{
ConfigureScripts(c);
@ -232,10 +240,7 @@ public sealed partial class SchemaDomainObject : DomainObject<SchemaDomainObject
}, ct);
case DeleteSchema deleteSchema:
return Update(deleteSchema, c =>
{
Delete(c);
}, ct);
return Apply(deleteSchema, Delete, ct);
default:
ThrowHelper.NotSupportedException();

28
backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs

@ -39,14 +39,24 @@ public partial class TeamDomainObject : DomainObject<TeamDomainObject.State>
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<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -55,7 +65,7 @@ public partial class TeamDomainObject : DomainObject<TeamDomainObject.State>
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<TeamDomainObject.State>
}, ct);
case UpdateTeam update:
return UpdateReturn(update, c =>
return ApplyReturn(update, c =>
{
GuardTeam.CanUpdate(c);
@ -75,7 +85,7 @@ public partial class TeamDomainObject : DomainObject<TeamDomainObject.State>
}, 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<TeamDomainObject.State>
}, 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<TeamDomainObject.State>
}, ct);
case ChangePlan changePlan:
return UpdateReturnAsync(changePlan, async (c, ct) =>
return ApplyReturnAsync(changePlan, async (c, ct) =>
{
GuardTeam.CanChangePlan(c, BillingPlans);

215
backend/src/Squidex.Infrastructure/Commands/DomainObject.Execute.cs

@ -9,133 +9,40 @@ namespace Squidex.Infrastructure.Commands;
public partial class DomainObject<T>
{
protected Task<CommandResult> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> handler,
protected async Task<CommandResult> ApplyReturnAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> handler,
CancellationToken ct = default) where TCommand : ICommand
{
EnsureCanCreate(command);
return UpsertCoreAsync(command, handler, true, ct);
return await UpsertCoreAsync(command, handler, ct);
}
protected Task<CommandResult> CreateReturn<TCommand>(TCommand command, Func<TCommand, object?> handler,
protected async Task<CommandResult> ApplyReturn<TCommand>(TCommand command, Func<TCommand, object?> 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<CommandResult> CreateAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task> 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<CommandResult> Create<TCommand>(TCommand command, Action<TCommand> handler,
CancellationToken ct = default) where TCommand : ICommand
{
return CreateAsync(command, (c, ct) =>
{
handler(c);
return Task.FromResult<object?>(None.Value);
}, ct);
}
protected async Task<CommandResult> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> handler,
protected async Task<CommandResult> ApplyAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task> handler,
CancellationToken ct = default) where TCommand : ICommand
{
await EnsureCanUpdateAsync(command, ct);
return await UpsertCoreAsync(command, handler, false, ct);
}
protected Task<CommandResult> UpdateReturn<TCommand>(TCommand command, Func<TCommand, object?> handler,
CancellationToken ct = default) where TCommand : ICommand
{
return UpdateReturnAsync(command, (c, ct) =>
{
var result = handler(c);
return Task.FromResult(result);
}, ct);
}
protected async Task<CommandResult> UpdateAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task> 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<CommandResult> Update<TCommand>(TCommand command, Action<TCommand> handler,
CancellationToken ct = default) where TCommand : ICommand
{
return await UpdateAsync(command, (c, _) =>
{
handler(c);
return Task.FromResult<object?>(None.Value);
}, ct);
}
protected async Task<CommandResult> UpsertReturnAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> handler,
CancellationToken ct = default) where TCommand : ICommand
{
await EnsureCanUpsertAsync(command, ct);
return await UpsertCoreAsync(command, handler, true, ct);
}
protected async Task<CommandResult> UpsertReturn<TCommand>(TCommand command, Func<TCommand, object?> handler,
CancellationToken ct = default) where TCommand : ICommand
{
return await UpsertReturnAsync(command, (c, _) =>
{
var result = handler(c);
return Task.FromResult(result);
}, ct);
}
protected async Task<CommandResult> UpsertAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task> 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<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler,
protected async Task<CommandResult> Apply<TCommand>(TCommand command, Action<TCommand> 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<object?>(None.Value);
}, ct);
}
@ -145,8 +52,6 @@ public partial class DomainObject<T>
{
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<T>
{
Guard.NotNull(handler);
return await DeletePermanentAsync(command, (c, _) =>
return await DeleteCoreAsync(command, (c, _) =>
{
handler(c);
return Task.FromResult<object?>(None.Value);
}, ct);
}
private void EnsureCanCreate<TCommand>(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>(TCommand command,
CancellationToken ct) where TCommand : ICommand
{
Guard.NotNull(command);
await EnsureLoadedAsync(ct);
NotDeleted();
NotEmpty();
MatchingVersion(command);
MatchingCommand(command);
}
private async Task EnsureCanUpsertAsync<TCommand>(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>(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>(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>(TCommand command) where TCommand : ICommand
{
if (!CanAcceptCreation(command))
{
throw new DomainException("Invalid command.");
}
}
private void MatchingCommand<TCommand>(TCommand command) where TCommand : ICommand
{
if (!CanAccept(command))
{
throw new DomainException("Invalid command.");
}
}
}

114
backend/src/Squidex.Infrastructure/Commands/DomainObject.cs

@ -39,6 +39,29 @@ public abstract partial class DomainObject<T> : 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<T> persistenceFactory,
ILogger log)
{
@ -170,6 +193,8 @@ public abstract partial class DomainObject<T> : 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<T> : IAggregate where T : class, IDom
}
}
private async Task<CommandResult> UpsertCoreAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> handler, bool isCreation,
private async Task<CommandResult> UpsertCoreAsync<TCommand>(TCommand command, Func<TCommand, CancellationToken, Task<object?>> 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<T> : 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<T> : IAggregate where T : class, IDom
}
}
protected virtual bool CanAcceptCreation(ICommand command)
private async Task EnsureCommandAsync<TCommand>(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>(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<T> : 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<T> : IAggregate where T : class, IDom
{
if (IsDeleted(snapshot))
{
if (!CanRecreate(@event.Payload))
if (!IsRecreation(@event.Payload))
{
return default;
}

16
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
}

2
backend/src/Squidex.Infrastructure/DomainId.cs

@ -55,7 +55,7 @@ public readonly struct DomainId : IEquatable<DomainId>, IComparable<DomainId>
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)

3
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

1
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;

6
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
{

6
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; }
/// <summary>
/// Also return deleted content items.
/// </summary>
[FromQuery(Name = "deleted")]
public bool Deleted { get; set; }
/// <summary>
/// True to force a new resize even if it already stored.
/// </summary>

6
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<Context>._, assetId, EtagVersion.Any, A<CancellationToken>._))
A.CallTo(() => assetQuery.FindAsync(A<Context>._, assetId, false, EtagVersion.Any, A<CancellationToken>._))
.Returns(asset);
var vars = new TemplateVars
@ -349,10 +349,10 @@ public class AssetsFluidExtensionTests : GivenContext
AppId = AppId
};
A.CallTo(() => assetQuery.FindAsync(A<Context>._, assetId1, EtagVersion.Any, A<CancellationToken>._))
A.CallTo(() => assetQuery.FindAsync(A<Context>._, assetId1, false, EtagVersion.Any, A<CancellationToken>._))
.Returns(asset1);
A.CallTo(() => assetQuery.FindAsync(A<Context>._, assetId2, EtagVersion.Any, A<CancellationToken>._))
A.CallTo(() => assetQuery.FindAsync(A<Context>._, assetId2, false, EtagVersion.Any, A<CancellationToken>._))
.Returns(asset2);
var vars = new TemplateVars

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryIntegrationTests.cs

@ -48,7 +48,7 @@ public class AssetsQueryIntegrationTests : IClassFixture<AssetsQueryFixture>, 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);

16
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<CancellationToken>._))
A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", true, A<CancellationToken>._))
.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<CancellationToken>._))
A.CallTo(() => assetRepository.FindAssetBySlugAsync(AppId.Id, "slug", false, A<CancellationToken>._))
.Returns(Task.FromResult<IAssetEntity?>(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<CancellationToken>._))
A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, false, A<CancellationToken>._))
.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<CancellationToken>._))
A.CallTo(() => assetRepository.FindAssetAsync(AppId.Id, asset.Id, false, A<CancellationToken>._))
.Returns(Task.FromResult<IAssetEntity?>(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<CancellationToken>._))
.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<CancellationToken>._))
.Returns(Task.FromResult<IAssetEntity?>(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);
}

65
backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs

@ -24,7 +24,6 @@ public class DomainObjectTests
ct = cts.Token;
state = new TestState<MyDomainState>(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<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), ct))
.MustHaveHappenedANumberOfTimesMatching(x => x == 3);
.MustHaveHappenedANumberOfTimesMatching(x => x == 2);
A.CallTo(() => state.Persistence.ReadAsync(A<long>._, 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<IReadOnlyList<Envelope<IEvent>>>._, ct))
.Throws(new InconsistentStateException(2, -1)).Once();
await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto(), ct));
await Assert.ThrowsAsync<DomainObjectDeletedException>(() => 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<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), ct))
.MustHaveHappenedANumberOfTimesMatching(x => x == 3);
.MustHaveHappenedANumberOfTimesMatching(x => x == 2);
A.CallTo(() => state.Persistence.ReadAsync(A<long>._, 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<IReadOnlyList<Envelope<IEvent>>>._, ct))
.Throws(new InconsistentStateException(4, EtagVersion.Empty));
.Invokes(() =>
{
SetupCreated(4);
throw new InconsistentStateException(4, EtagVersion.Empty);
});
await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto(), ct));
}
@ -353,12 +356,6 @@ public class DomainObjectTests
await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto(), ct));
}
[Fact]
public async Task Should_throw_exception_if_create_command_not_accepted()
{
await Assert.ThrowsAsync<DomainException>(() => 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<IPersistence<MyDomainState>>();
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<IReadOnlyList<Envelope<IEvent>>>._, ct))
.MustNotHaveHappened();
A.CallTo(() => deleteStream.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, A<CancellationToken>._))
.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<IPersistence<MyDomainState>>();
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<IReadOnlyList<Envelope<IEvent>>>._, ct))
.MustHaveHappenedOnceExactly();
.MustNotHaveHappened();
A.CallTo(() => deleteStream.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, A<CancellationToken>._))
.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)

62
backend/tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

@ -16,7 +16,7 @@ namespace Squidex.Infrastructure.TestHelpers;
public sealed class MyDomainObject : DomainObject<MyDomainState>
{
public bool Recreate { get; set; }
public bool RecreateCommand { get; set; }
public bool RecreateEvent { get; set; }
@ -25,28 +25,23 @@ public sealed class MyDomainObject : DomainObject<MyDomainState>
{
}
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<MyDomainState>
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<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -66,19 +88,19 @@ public sealed class MyDomainObject : DomainObject<MyDomainState>
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<MyDomainState>
}, 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<MyDomainState>
}, ct);
case Delete c:
return Update(c, delete =>
return Apply(c, delete =>
{
RaiseEvent(new Deleted());
}, ct);

158
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<CreatedAppFixture>
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<CreatedAppFixture>
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<CreatedAppFixture>
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<int> Progress { get; } = new List<int>();
public List<int> Uploads { get; } = new List<int>();
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<int> ReadAsync(Memory<byte> 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);
}
}

2
tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -25,7 +25,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NSwag.Core" Version="13.18.2" />
<PackageReference Include="PuppeteerSharp" Version="9.0.2" />
<PackageReference Include="Squidex.Assets" Version="5.5.0" />
<PackageReference Include="Squidex.Assets" Version="5.16.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify.Xunit" Version="19.11.2" />
<PackageReference Include="xunit" Version="2.4.2" />

39
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<AssetDto> 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<AssetDto> 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<AssetDto> UploadFileAsync(this IAssetsClient assetsClients, string path, string fileType, string fileName = null, string parentId = null, string id = null)
public static async Task<AssetDto> 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<AssetDto> UpdateFileAsync(this IAssetsClient assetsClients, string id, string path, string fileType, string fileName = null)
public static async Task<AssetDto> 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<AssetDto> UploadRandomFileAsync(this IAssetsClient assetsClients, int size, string parentId = null, string id = null)
public static async Task<AssetDto> 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);
}
}

5
tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -16,8 +16,9 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="16.0.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="16.0.0" />
<PackageReference Include="Squidex.Assets" Version="5.16.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="16.1.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="16.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify" Version="19.11.2" />
<PackageReference Include="xunit" Version="2.4.2" />

75
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<int> ReadAsync(Memory<byte> 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;
}
}

66
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<int> Progress { get; } = new List<int>();
public List<int> Uploads { get; } = new List<int>();
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;
}
}
Loading…
Cancel
Save