Browse Source

Update packages.

pull/1330/head
Sebastian Stehle 2 weeks ago
parent
commit
a036084e6a
  1. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  2. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  3. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  4. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  5. 28
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs
  6. 143
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  7. 44
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflows.cs
  8. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  9. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflows.cs
  10. 58
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  11. 38
      backend/src/Squidex.Domain.Apps.Entities/Contents/WorkflowDefinition.cs
  12. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  13. 10
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  14. 6
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs
  15. 18
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  16. 14
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs
  17. 4
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  18. 4
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  19. 22
      backend/src/Squidex/Squidex.csproj
  20. 23
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
  21. 27
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs
  22. 189
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs
  23. 64
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs
  24. 1236
      resolved.md
  25. 100
      todo.md

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -43,13 +43,13 @@
<PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" />
<PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.4" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" /> <PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.AI.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Events.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Events.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Flows.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Flows.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="8.0.3" /> <PackageReference Include="Squidex.Flows" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup> </ItemGroup>

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.6.1" /> <PackageReference Include="NJsonSchema" Version="11.6.1" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="8.0.3" /> <PackageReference Include="Squidex.AI" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />
<PackageReference Include="ValueTaskSupplement" Version="1.1.0" /> <PackageReference Include="ValueTaskSupplement" Version="1.1.0" />

28
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs

@ -14,27 +14,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards;
public static class WorkflowExtensions public static class WorkflowExtensions
{ {
public static ValueTask<Status> GetInitialStatusAsync(this ContentOperation operation) public static async ValueTask<Status> GetInitialStatusAsync(this ContentOperation operation)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
return workflow.GetInitialStatusAsync(operation.Schema); return workflow.GetInitialStatus();
} }
public static ValueTask<bool> ShouldValidateAsync(this ContentOperation operation, Status status) public static async ValueTask<bool> ShouldValidateAsync(this ContentOperation operation, Status status)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
return workflow.ShouldValidateAsync(operation.Schema, status); return workflow.ShouldValidate(status);
} }
public static async Task CheckTransitionAsync(this ContentOperation operation, Status status) public static async Task CheckTransitionAsync(this ContentOperation operation, Status status)
{ {
if (operation.Schema.Type != SchemaType.Singleton) if (operation.Schema.Type != SchemaType.Singleton)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
if (!await workflow.CanMoveToAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, status, operation.User)) if (!workflow.CanMoveTo(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, status, operation.User))
{ {
var values = new { oldStatus = operation.Snapshot.EditingStatus, newStatus = status }; var values = new { oldStatus = operation.Snapshot.EditingStatus, newStatus = status };
@ -48,9 +48,9 @@ public static class WorkflowExtensions
{ {
if (operation.Schema.Type != SchemaType.Singleton) if (operation.Schema.Type != SchemaType.Singleton)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
var statusInfo = await workflow.GetInfoAsync(operation.Snapshot.ToContent(), status); var statusInfo = workflow.GetInfo(status);
if (statusInfo == null) if (statusInfo == null)
{ {
@ -64,17 +64,17 @@ public static class WorkflowExtensions
{ {
if (operation.User != null) if (operation.User != null)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
if (!await workflow.CanUpdateAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User)) if (!workflow.CanUpdate(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User))
{ {
throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status = operation.Snapshot.EditingStatus })); throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status = operation.Snapshot.EditingStatus }));
} }
} }
} }
private static IContentWorkflow GetWorkflow(ContentOperation operation) private static ValueTask<IContentWorkflow> GetWorkflowAsync(ContentOperation operation)
{ {
return operation.Resolve<IContentWorkflow>(); return operation.Resolve<IContentWorkflows>().GetWorkflowAsync(operation.App, operation.Schema);
} }
} }

143
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -7,95 +7,104 @@
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents; namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider) : IContentWorkflow public sealed class DynamicContentWorkflow(WorkflowDefinition definition, IScriptEngine scriptEngine) : IContentWorkflow
{ {
public async ValueTask<StatusInfo[]> GetAllAsync(Schema schema) // The same expression is evaluated for every content of a batch, therefore the compiled script is
{ // kept around. A script that cannot be compiled is stored as null to not retry it over and over.
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); private readonly Dictionary<string, IScript?> scripts = [];
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); // The next statuses of a step without any condition are the same for every content and user.
} private readonly Dictionary<Status, StatusInfo[]> nextStatuses = [];
public async ValueTask<bool> CanPublishInitialAsync(Schema schema, ClaimsPrincipal? user) public StatusInfo[] GetAll()
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return definition.AllStatuses;
return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, null, user);
} }
public async ValueTask<bool> CanMoveToAsync(Content content, Status status, Status next, ClaimsPrincipal? user) public Status GetInitialStatus()
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); return definition.Workflow.Initial;
return workflow.TryGetTransition(status, next, out var transition) && IsTrue(transition, content.Data, user);
} }
public async ValueTask<bool> CanUpdateAsync(Content content, Status status, ClaimsPrincipal? user) public StatusInfo? GetInfo(Status status)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); return definition.StatusInfos.GetValueOrDefault(status);
if (workflow.TryGetStep(status, out var step))
{
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.Data, user);
}
return true;
} }
public async ValueTask<bool> ShouldValidateAsync(Schema schema, Status status) public bool ShouldValidate(Status status)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); if (definition.Workflow.TryGetStep(status, out var step) && step.Validate)
if (workflow.TryGetStep(status, out var step) && step.Validate)
{ {
return true; return true;
} }
return status == Status.Published && schema.Properties.ValidateOnPublish; return status == Status.Published && definition.ValidateOnPublish;
} }
public async ValueTask<StatusInfo?> GetInfoAsync(Content content, Status status) public bool CanPublishInitial(ClaimsPrincipal? user)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); var workflow = definition.Workflow;
if (workflow.TryGetStep(status, out var step)) return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, null, user);
{
return new StatusInfo(status, GetColor(step));
}
return null;
} }
public async ValueTask<Status> GetInitialStatusAsync(Schema schema) public bool CanMoveTo(Content content, Status status, Status next, ClaimsPrincipal? user)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return definition.Workflow.TryGetTransition(status, next, out var transition) && IsTrue(transition, content.Data, user);
}
var (status, _) = workflow.GetInitialStepId(); public bool CanUpdate(Content content, Status status, ClaimsPrincipal? user)
{
if (definition.Workflow.TryGetStep(status, out var step))
{
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.Data, user);
}
return status; return true;
} }
public async ValueTask<StatusInfo[]> GetNextAsync(Content content, Status status, ClaimsPrincipal? user) public StatusInfo[] GetNext(Content content, Status status, ClaimsPrincipal? user)
{ {
var result = new List<StatusInfo>(); if (nextStatuses.TryGetValue(status, out var cached))
{
return cached;
}
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); List<StatusInfo>? result = null;
foreach (var (to, step, transition) in workflow.GetTransitions(status)) var isStatic = true;
foreach (var (to, _, transition) in definition.Workflow.GetTransitions(status))
{ {
isStatic = isStatic && IsUnconditional(transition);
if (IsTrue(transition, content.Data, user)) if (IsTrue(transition, content.Data, user))
{ {
result.Add(new StatusInfo(to, GetColor(step))); result ??= [];
result.Add(definition.StatusInfos[to]);
} }
} }
return result.ToArray(); var statuses = result?.ToArray() ?? [];
if (isStatic)
{
nextStatuses[status] = statuses;
}
return statuses;
}
public void Dispose()
{
foreach (var script in scripts.Values)
{
script?.Dispose();
}
scripts.Clear();
} }
private bool IsTrue(WorkflowCondition condition, ContentData? data, ClaimsPrincipal? user) private bool IsTrue(WorkflowCondition condition, ContentData? data, ClaimsPrincipal? user)
@ -110,43 +119,45 @@ public sealed class DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvi
if (!string.IsNullOrWhiteSpace(condition?.Expression) && data != null) if (!string.IsNullOrWhiteSpace(condition?.Expression) && data != null)
{ {
var script = GetScript(condition.Expression);
if (script == null)
{
return false;
}
var vars = new DataScriptVars var vars = new DataScriptVars
{ {
Data = data, Data = data,
}; };
return scriptEngine.Evaluate(vars, condition.Expression); return script.Evaluate(vars);
} }
return true; return true;
} }
private async ValueTask<Workflow> GetWorkflowAsync(DomainId appId, DomainId schemaId) private IScript? GetScript(string expression)
{ {
Workflow? result = null; if (scripts.TryGetValue(expression, out var script))
var app = await appProvider.GetAppAsync(appId, false);
if (app != null)
{ {
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId)); return script;
if (result == null)
{
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0);
}
} }
if (result == null) try
{
script = scriptEngine.CreateScript(expression);
}
catch
{ {
result = Workflow.Default; script = null;
} }
return result; scripts[expression] = script;
return script;
} }
private static string GetColor(WorkflowStep step) private static bool IsUnconditional(WorkflowCondition condition)
{ {
return step.Color ?? StatusColors.Draft; return condition.Roles == null && string.IsNullOrWhiteSpace(condition.Expression);
} }
} }

44
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflows.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Caching;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting;
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class DynamicContentWorkflows(IScriptEngine scriptEngine, ILocalCache localCache) : IContentWorkflows
{
public ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema,
CancellationToken ct = default)
{
// The definition is only cached for the current request, because the workflow must never be
// stale. It is resolved several times per request, especially once per command guard.
var cacheKey = (nameof(DynamicContentWorkflows), app.Id, app.Version, schema.Id, schema.Version);
if (!localCache.TryGetValue(cacheKey, out var cached) || cached is not WorkflowDefinition definition)
{
definition = CreateDefinition(app, schema);
localCache.Add(cacheKey, definition);
}
return new ValueTask<IContentWorkflow>(new DynamicContentWorkflow(definition, scriptEngine));
}
private static WorkflowDefinition CreateDefinition(App app, Schema schema)
{
var workflow =
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schema.Id)) ??
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0) ??
Workflow.Default;
return new WorkflowDefinition(workflow, schema.Properties.ValidateOnPublish);
}
}

19
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -7,25 +7,24 @@
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents; namespace Squidex.Domain.Apps.Entities.Contents;
public interface IContentWorkflow public interface IContentWorkflow : IDisposable
{ {
ValueTask<Status> GetInitialStatusAsync(Schema schema); Status GetInitialStatus();
ValueTask<bool> CanMoveToAsync(Content content, Status status, Status next, ClaimsPrincipal? user); bool CanMoveTo(Content content, Status status, Status next, ClaimsPrincipal? user);
ValueTask<bool> CanUpdateAsync(Content content, Status status, ClaimsPrincipal? user); bool CanUpdate(Content content, Status status, ClaimsPrincipal? user);
ValueTask<bool> CanPublishInitialAsync(Schema schema, ClaimsPrincipal? user); bool CanPublishInitial(ClaimsPrincipal? user);
ValueTask<bool> ShouldValidateAsync(Schema schema, Status status); bool ShouldValidate(Status status);
ValueTask<StatusInfo?> GetInfoAsync(Content content, Status status); StatusInfo? GetInfo(Status status);
ValueTask<StatusInfo[]> GetNextAsync(Content content, Status status, ClaimsPrincipal? user); StatusInfo[] GetNext(Content content, Status status, ClaimsPrincipal? user);
ValueTask<StatusInfo[]> GetAllAsync(Schema schema); StatusInfo[] GetAll();
} }

17
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflows.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents;
public interface IContentWorkflows
{
ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema,
CancellationToken ct = default);
}

58
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -6,34 +6,44 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps; namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
public sealed class EnrichWithWorkflows(IContentWorkflow contentWorkflow) : IContentEnricherStep public sealed class EnrichWithWorkflows(IContentWorkflows contentWorkflows) : IContentEnricherStep
{ {
private const string DefaultColor = StatusColors.Draft; private const string DefaultColor = StatusColors.Draft;
public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas, public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas,
CancellationToken ct) CancellationToken ct)
{ {
var cache = new Dictionary<(DomainId, Status), StatusInfo>(); var withStatuses = ShouldEnrichWithStatuses(context);
foreach (var content in contents) foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
await EnrichColorAsync(content, cache); var (schema, _) = await schemas(group.Key);
if (ShouldEnrichWithStatuses(context)) // The workflow is resolved once per schema and caches the compiled conditions and the
// status colors for all contents of the group.
using var workflow = await contentWorkflows.GetWorkflowAsync(context.App, schema, ct);
foreach (var content in group)
{ {
await EnrichNextsAsync(content, context); ct.ThrowIfCancellationRequested();
await EnrichCanUpdateAsync(content, context);
EnrichColor(content, workflow);
if (withStatuses)
{
EnrichNexts(content, workflow, context);
EnrichCanUpdate(content, workflow, context);
}
} }
} }
} }
private async Task EnrichNextsAsync(EnrichedContent content, Context context) private static void EnrichNexts(EnrichedContent content, IContentWorkflow workflow, Context context)
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
@ -53,47 +63,35 @@ public sealed class EnrichWithWorkflows(IContentWorkflow contentWorkflow) : ICon
} }
else else
{ {
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.UserPrincipal); content.NextStatuses = workflow.GetNext(content, editingStatus, context.UserPrincipal);
} }
} }
private async Task EnrichCanUpdateAsync(EnrichedContent content, Context context) private static void EnrichCanUpdate(EnrichedContent content, IContentWorkflow workflow, Context context)
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, editingStatus, context.UserPrincipal); content.CanUpdate = workflow.CanUpdate(content, editingStatus, context.UserPrincipal);
} }
private async Task EnrichColorAsync(EnrichedContent content, Dictionary<(DomainId, Status), StatusInfo> cache) private static void EnrichColor(EnrichedContent content, IContentWorkflow workflow)
{ {
content.StatusColor = await GetColorAsync(content, content.Status, cache); content.StatusColor = GetColor(workflow, content.Status);
if (content.NewStatus != null) if (content.NewStatus != null)
{ {
content.NewStatusColor = await GetColorAsync(content, content.NewStatus.Value, cache); content.NewStatusColor = GetColor(workflow, content.NewStatus.Value);
} }
if (content.ScheduleJob != null) if (content.ScheduleJob != null)
{ {
content.ScheduledStatusColor = await GetColorAsync(content, content.ScheduleJob.Status, cache); content.ScheduledStatusColor = GetColor(workflow, content.ScheduleJob.Status);
} }
} }
private async Task<string> GetColorAsync(Content content, Status status, Dictionary<(DomainId, Status), StatusInfo> cache) private static string GetColor(IContentWorkflow workflow, Status status)
{ {
if (!cache.TryGetValue((content.SchemaId.Id, status), out var info)) return workflow.GetInfo(status)?.Color ?? DefaultColor;
{
info = await contentWorkflow.GetInfoAsync(content, status);
if (info == null)
{
info = new StatusInfo(status, DefaultColor);
}
cache[(content.SchemaId.Id, status)] = info;
}
return info.Color;
} }
private static bool ShouldEnrichWithStatuses(Context context) private static bool ShouldEnrichWithStatuses(Context context)

38
backend/src/Squidex.Domain.Apps.Entities/Contents/WorkflowDefinition.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class WorkflowDefinition
{
public Workflow Workflow { get; }
public IReadOnlyDictionary<Status, StatusInfo> StatusInfos { get; }
public StatusInfo[] AllStatuses { get; }
public bool ValidateOnPublish { get; }
public WorkflowDefinition(Workflow workflow, bool validateOnPublish)
{
// The status infos never change for a workflow, therefore they are created once and shared
// by all contents instead of allocating them for every single status lookup.
var statusInfos = new Dictionary<Status, StatusInfo>(workflow.Steps.Count);
foreach (var (status, step) in workflow.Steps)
{
statusInfos[status] = new StatusInfo(status, step.Color ?? StatusColors.Draft);
}
Workflow = workflow;
StatusInfos = statusInfos;
AllStatuses = [.. statusInfos.Values];
ValidateOnPublish = validateOnPublish;
}
}

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.3.1" /> <PackageReference Include="NodaTime" Version="3.3.1" />
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="8.0.3" /> <PackageReference Include="Squidex.Assets" Version="8.0.4" />
<PackageReference Include="Squidex.Caching" Version="8.0.3" /> <PackageReference Include="Squidex.Caching" Version="8.0.4" />
<PackageReference Include="Squidex.Events" Version="8.0.3" /> <PackageReference Include="Squidex.Events" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.4" />
<PackageReference Include="Squidex.Log" Version="8.0.3" /> <PackageReference Include="Squidex.Log" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging" Version="8.0.4" />
<PackageReference Include="Squidex.Text" Version="8.0.3" /> <PackageReference Include="Squidex.Text" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />

10
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -26,7 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Contents;
public sealed class ContentsController( public sealed class ContentsController(
ICommandBus commandBus, ICommandBus commandBus,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentWorkflow contentWorkflow) IContentWorkflows contentWorkflows)
: ApiController(commandBus) : ApiController(commandBus)
{ {
/// <summary> /// <summary>
@ -83,7 +83,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -117,7 +117,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -216,7 +216,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -251,7 +251,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);

6
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs

@ -25,7 +25,7 @@ namespace Squidex.Areas.Api.Controllers.Contents;
public sealed class ContentsSharedController( public sealed class ContentsSharedController(
ICommandBus commandBus, ICommandBus commandBus,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentWorkflow contentWorkflow) IContentWorkflows contentWorkflows)
: ApiController(commandBus) : ApiController(commandBus)
{ {
private static readonly GraphQLHttpMiddlewareOptions GraphQLOptions = new GraphQLHttpMiddlewareOptions private static readonly GraphQLHttpMiddlewareOptions GraphQLOptions = new GraphQLHttpMiddlewareOptions
@ -146,7 +146,7 @@ public sealed class ContentsSharedController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -179,7 +179,7 @@ public sealed class ContentsSharedController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);

18
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -30,7 +30,7 @@ public sealed class ContentsDto : Resource
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<ContentsDto> FromContentsAsync(IResultList<EnrichedContent> contents, Resources resources, public static async Task<ContentsDto> FromContentsAsync(IResultList<EnrichedContent> contents, Resources resources,
Schema? schema, IContentWorkflow workflow) Schema? schema, IContentWorkflows workflows)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
@ -40,8 +40,10 @@ public sealed class ContentsDto : Resource
if (schema != null) if (schema != null)
{ {
await result.CreateStatusesAsync(workflow, schema); using var workflow = await workflows.GetWorkflowAsync(resources.Context.App, schema);
await result.CreateLinksAsync(resources, workflow, schema);
result.CreateStatuses(workflow);
result.CreateLinks(resources, workflow, schema);
} }
else else
{ {
@ -51,14 +53,12 @@ public sealed class ContentsDto : Resource
return result; return result;
} }
private async Task CreateStatusesAsync(IContentWorkflow workflow, Schema schema) private void CreateStatuses(IContentWorkflow workflow)
{ {
var allStatuses = await workflow.GetAllAsync(schema); Statuses = workflow.GetAll().Select(StatusInfoDto.FromDomain).ToArray();
Statuses = allStatuses.Select(StatusInfoDto.FromDomain).ToArray();
} }
private async Task CreateLinksAsync(Resources resources, IContentWorkflow workflow, Schema schema) private void CreateLinks(Resources resources, IContentWorkflow workflow, Schema schema)
{ {
var values = new { app = resources.App, schema = schema.Name }; var values = new { app = resources.App, schema = schema.Name };
@ -69,7 +69,7 @@ public sealed class ContentsDto : Resource
AddPostLink("create", AddPostLink("create",
resources.Url<ContentsController>(x => nameof(x.PostContent), values)); resources.Url<ContentsController>(x => nameof(x.PostContent), values));
if (resources.CanChangeStatus(values.schema) && await workflow.CanPublishInitialAsync(schema, resources.Context.UserPrincipal)) if (resources.CanChangeStatus(values.schema) && workflow.CanPublishInitial(resources.Context.UserPrincipal))
{ {
var publishValues = new { values.app, values.schema, publish = true }; var publishValues = new { values.app, values.schema, publish = true };

14
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
@ -20,22 +21,17 @@ public sealed class QueryModelDto
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<QueryModelDto> FromModelAsync(QueryModel model, Schema? schema, IContentWorkflow workflow) public static async Task<QueryModelDto> FromModelAsync(QueryModel model, App app, Schema? schema, IContentWorkflows workflows)
{ {
var result = SimpleMapper.Map(model, new QueryModelDto()); var result = SimpleMapper.Map(model, new QueryModelDto());
if (schema != null) if (schema != null)
{ {
await result.AssignStatusesAsync(workflow, schema); using var workflow = await workflows.GetWorkflowAsync(app, schema);
result.Statuses = workflow.GetAll().Select(StatusInfoDto.FromDomain).ToArray();
} }
return result; return result;
} }
private async Task AssignStatusesAsync(IContentWorkflow workflow, Schema schema)
{
var allStatuses = await workflow.GetAllAsync(schema);
Statuses = allStatuses.Select(StatusInfoDto.FromDomain).ToArray();
}
} }

4
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -30,7 +30,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas;
[ApiExplorerSettings(GroupName = nameof(Schemas))] [ApiExplorerSettings(GroupName = nameof(Schemas))]
public sealed class SchemasController( public sealed class SchemasController(
ICommandBus commandBus, ICommandBus commandBus,
IContentWorkflow workflow, IContentWorkflows workflows,
IAppProvider appProvider, IAppProvider appProvider,
SchemaAIGenerator schemaAIGenerator, SchemaAIGenerator schemaAIGenerator,
ScriptingCompleter scriptingCompleter) ScriptingCompleter scriptingCompleter)
@ -395,7 +395,7 @@ public sealed class SchemasController(
var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted); var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted);
var result = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten(); var result = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten();
var response = await QueryModelDto.FromModelAsync(result, Schema, workflow); var response = await QueryModelDto.FromModelAsync(result, App, Schema, workflows);
return Ok(response); return Ok(response);
} }

4
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -90,8 +90,8 @@ public static class ContentsServices
services.AddSingletonAs<ContentLoader>() services.AddSingletonAs<ContentLoader>()
.As<IContentLoader>(); .As<IContentLoader>();
services.AddSingletonAs<DynamicContentWorkflow>() services.AddSingletonAs<DynamicContentWorkflows>()
.AsOptional<IContentWorkflow>(); .AsOptional<IContentWorkflows>();
services.AddSingletonAs<DefaultWorkflowsValidator>() services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>(); .AsOptional<IWorkflowsValidator>();

22
backend/src/Squidex/Squidex.csproj

@ -57,16 +57,16 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.Azure" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.FTP" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.FTP" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.S3" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.S3" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.4" />
<PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.All" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.All" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.6.0" /> <PackageReference Include="YDotNet" Version="0.6.0" />
<PackageReference Include="YDotNet.Native" Version="0.6.0" /> <PackageReference Include="YDotNet.Native" Version="0.6.0" />
@ -80,12 +80,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'"> <ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.4" />
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" /> <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'"> <ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.4" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

23
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs

@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -29,6 +30,7 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
{ {
private readonly DomainId contentId = DomainId.NewGuid(); private readonly DomainId contentId = DomainId.NewGuid();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(); private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>();
private readonly IContentWorkflows contentWorkflows = A.Fake<IContentWorkflows>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>(); private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
@ -100,22 +102,25 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
A.CallTo(() => scriptEngine.Execute(A<ScriptVars>._, A<string>._, A<ScriptOptions>._)) A.CallTo(() => scriptEngine.Execute(A<ScriptVars>._, A<string>._, A<ScriptOptions>._))
.Returns(JsonValue.Create(43)); .Returns(JsonValue.Create(43));
A.CallTo(() => contentWorkflow.GetInitialStatusAsync(Schema)) A.CallTo(() => contentWorkflows.GetWorkflowAsync(A<App>._, A<Schema>._, A<CancellationToken>._))
.Returns(contentWorkflow);
A.CallTo(() => contentWorkflow.GetInitialStatus())
.Returns(Status.Draft); .Returns(Status.Draft);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Draft, Status.Published, A<ClaimsPrincipal?>._)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Draft, Status.Published, A<ClaimsPrincipal?>._))
.Returns(true); .Returns(true);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Draft, Status.Archived, A<ClaimsPrincipal?>._)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Draft, Status.Archived, A<ClaimsPrincipal?>._))
.Returns(true); .Returns(true);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Published, Status.Draft, A<ClaimsPrincipal?>._)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Published, Status.Draft, A<ClaimsPrincipal?>._))
.Returns(true); .Returns(true);
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Published, Status.Archived, A<ClaimsPrincipal?>._)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Published, Status.Archived, A<ClaimsPrincipal?>._))
.Returns(true); .Returns(true);
A.CallTo(() => contentWorkflow.CanUpdateAsync(A<Content>._, A<Status>._, A<ClaimsPrincipal?>._)) A.CallTo(() => contentWorkflow.CanUpdate(A<Content>._, A<Status>._, A<ClaimsPrincipal?>._))
.Returns(true); .Returns(true);
patched = patch.MergeInto(data); patched = patch.MergeInto(data);
@ -127,7 +132,7 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
.AddSingleton(AppProvider) .AddSingleton(AppProvider)
.AddSingleton(A.Fake<ILogger<ContentValidator>>()) .AddSingleton(A.Fake<ILogger<ContentValidator>>())
.AddSingleton(log) .AddSingleton(log)
.AddSingleton(contentWorkflow) .AddSingleton(contentWorkflows)
.AddSingleton(contentRepository) .AddSingleton(contentRepository)
.AddSingleton(scriptEngine) .AddSingleton(scriptEngine)
.AddSingleton(TestUtils.DefaultSerializer) .AddSingleton(TestUtils.DefaultSerializer)
@ -612,7 +617,7 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
var command = new ChangeContentStatus { Status = Status.Archived, StatusJobId = sut.Snapshot.ScheduleJob!.Id }; var command = new ChangeContentStatus { Status = Status.Archived, StatusJobId = sut.Snapshot.ScheduleJob!.Id };
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Draft, Status.Archived, ApiContext.UserPrincipal)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Draft, Status.Archived, ApiContext.UserPrincipal))
.Returns(true); .Returns(true);
var actual = await PublishAsync(command); var actual = await PublishAsync(command);
@ -633,7 +638,7 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
var command = new ChangeContentStatus { Status = Status.Published, StatusJobId = sut.Snapshot.ScheduleJob!.Id }; var command = new ChangeContentStatus { Status = Status.Published, StatusJobId = sut.Snapshot.ScheduleJob!.Id };
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<Content>._, Status.Draft, Status.Published, ApiContext.UserPrincipal)) A.CallTo(() => contentWorkflow.CanMoveTo(A<Content>._, Status.Draft, Status.Published, ApiContext.UserPrincipal))
.Returns(false); .Returns(false);
var actual = await PublishAsync(command); var actual = await PublishAsync(command);

27
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs

@ -7,6 +7,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
@ -24,6 +25,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards;
public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture> public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture>
{ {
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(); private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>();
private readonly IContentWorkflows contentWorkflows = A.Fake<IContentWorkflows>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>(); private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly Schema normalSchema; private readonly Schema normalSchema;
private readonly Schema normalUnpublishedSchema; private readonly Schema normalUnpublishedSchema;
@ -33,6 +35,9 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
public GuardContentTests() public GuardContentTests()
{ {
A.CallTo(() => contentWorkflows.GetWorkflowAsync(A<App>._, A<Schema>._, A<CancellationToken>._))
.Returns(contentWorkflow);
normalUnpublishedSchema = normalUnpublishedSchema =
Schema.Unpublish(); Schema.Unpublish();
@ -214,7 +219,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.GetInitialStatusAsync(operation.Schema)) A.CallTo(() => contentWorkflow.GetInitialStatus())
.Returns(Status.Archived); .Returns(Status.Archived);
Assert.Equal(Status.Archived, await operation.GetInitialStatusAsync()); Assert.Equal(Status.Archived, await operation.GetInitialStatusAsync());
@ -225,7 +230,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.CanUpdateAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User)) A.CallTo(() => contentWorkflow.CanUpdate(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User))
.Returns(false); .Returns(false);
await Assert.ThrowsAsync<DomainException>(() => operation.CheckUpdateAsync()); await Assert.ThrowsAsync<DomainException>(() => operation.CheckUpdateAsync());
@ -236,7 +241,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.CanUpdateAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User)) A.CallTo(() => contentWorkflow.CanUpdate(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User))
.Returns(true); .Returns(true);
await operation.CheckUpdateAsync(); await operation.CheckUpdateAsync();
@ -247,8 +252,8 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.GetInfoAsync(operation.Snapshot.ToContent(), Status.Archived)) A.CallTo(() => contentWorkflow.GetInfo(Status.Archived))
.Returns(ValueTask.FromResult<StatusInfo?>(null)); .Returns(null);
await Assert.ThrowsAsync<ValidationException>(() => operation.CheckStatusAsync(Status.Archived)); await Assert.ThrowsAsync<ValidationException>(() => operation.CheckStatusAsync(Status.Archived));
} }
@ -258,7 +263,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.GetInfoAsync(operation.Snapshot.ToContent(), Status.Archived)) A.CallTo(() => contentWorkflow.GetInfo(Status.Archived))
.Returns(new StatusInfo(Status.Archived, StatusColors.Archived)); .Returns(new StatusInfo(Status.Archived, StatusColors.Archived));
await operation.CheckStatusAsync(Status.Archived); await operation.CheckStatusAsync(Status.Archived);
@ -271,7 +276,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
await operation.CheckStatusAsync(Status.Archived); await operation.CheckStatusAsync(Status.Archived);
A.CallTo(() => contentWorkflow.GetInfoAsync(operation.Snapshot.ToContent(), Status.Archived)) A.CallTo(() => contentWorkflow.GetInfo(Status.Archived))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -280,7 +285,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.CanMoveToAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, Status.Archived, operation.User)) A.CallTo(() => contentWorkflow.CanMoveTo(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, Status.Archived, operation.User))
.Returns(false); .Returns(false);
await Assert.ThrowsAsync<ValidationException>(() => operation.CheckTransitionAsync(Status.Archived)); await Assert.ThrowsAsync<ValidationException>(() => operation.CheckTransitionAsync(Status.Archived));
@ -291,7 +296,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
{ {
var operation = Operation(CreateContent(Status.Draft), normalSchema); var operation = Operation(CreateContent(Status.Draft), normalSchema);
A.CallTo(() => contentWorkflow.CanMoveToAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, Status.Archived, operation.User)) A.CallTo(() => contentWorkflow.CanMoveTo(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, Status.Archived, operation.User))
.Returns(true); .Returns(true);
await operation.CheckTransitionAsync(Status.Archived); await operation.CheckTransitionAsync(Status.Archived);
@ -304,7 +309,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
await operation.CheckTransitionAsync(Status.Archived); await operation.CheckTransitionAsync(Status.Archived);
A.CallTo(() => contentWorkflow.CanMoveToAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, A<Status>._, A<ClaimsPrincipal>._)) A.CallTo(() => contentWorkflow.CanMoveTo(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, A<Status>._, A<ClaimsPrincipal>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -375,7 +380,7 @@ public class GuardContentTests : GivenContext, IClassFixture<TranslationsFixture
var serviceProvider = var serviceProvider =
new ServiceCollection() new ServiceCollection()
.AddSingleton(contentRepository) .AddSingleton(contentRepository)
.AddSingleton(contentWorkflow) .AddSingleton(contentWorkflows)
.BuildServiceProvider(); .BuildServiceProvider();
return new ContentOperation(serviceProvider, () => content) return new ContentOperation(serviceProvider, () => content)

189
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -7,6 +7,7 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Caching;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
@ -20,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public class DynamicContentWorkflowTests : GivenContext public class DynamicContentWorkflowTests : GivenContext
{ {
private readonly DomainId simpleSchemaId = DomainId.NewGuid(); private readonly DomainId simpleSchemaId = DomainId.NewGuid();
private readonly DynamicContentWorkflow sut; private readonly DynamicContentWorkflows sut;
private readonly Workflow workflow = new Workflow( private readonly Workflow workflow = new Workflow(
Status.Draft, Status.Draft,
@ -86,15 +87,15 @@ public class DynamicContentWorkflowTests : GivenContext
TimeoutExecution = TimeSpan.FromSeconds(10), TimeoutExecution = TimeSpan.FromSeconds(10),
})); }));
sut = new DynamicContentWorkflow(scriptEngine, AppProvider); sut = new DynamicContentWorkflows(scriptEngine, new AsyncLocalCache());
} }
[Fact] [Fact]
public async Task Should_return_info_for_valid_status() public async Task Should_return_info_for_valid_status()
{ {
var content = CreateContent(Status.Draft, 2); using var sutWorkflow = await GetWorkflowAsync();
var info = await sut.GetInfoAsync(content, Status.Draft); var info = sutWorkflow.GetInfo(Status.Draft);
Assert.Equal(new StatusInfo(Status.Draft, StatusColors.Draft), info); Assert.Equal(new StatusInfo(Status.Draft, StatusColors.Draft), info);
} }
@ -102,9 +103,9 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_return_info_as_null_for_invalid_status() public async Task Should_return_info_as_null_for_invalid_status()
{ {
var content = CreateContent(Status.Draft, 2); using var sutWorkflow = await GetWorkflowAsync();
var info = await sut.GetInfoAsync(content, new Status("Invalid")); var info = sutWorkflow.GetInfo(new Status("Invalid"));
Assert.Null(info); Assert.Null(info);
} }
@ -112,7 +113,9 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_return_draft_as_initial_status() public async Task Should_return_draft_as_initial_status()
{ {
var actual = await sut.GetInitialStatusAsync(Schema); using var sutWorkflow = await GetWorkflowAsync();
var actual = sutWorkflow.GetInitialStatus();
Assert.Equal(Status.Draft, actual); Assert.Equal(Status.Draft, actual);
} }
@ -120,7 +123,9 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_allow_publish_on_create() public async Task Should_allow_publish_on_create()
{ {
var actual = await sut.CanPublishInitialAsync(Schema, Mocks.FrontendUser(Role.Editor)); using var sutWorkflow = await GetWorkflowAsync();
var actual = sutWorkflow.CanPublishInitial(Mocks.FrontendUser(Role.Editor));
Assert.True(actual); Assert.True(actual);
} }
@ -128,7 +133,9 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_allow_publish_on_create_if_role_not_allowed() public async Task Should_not_allow_publish_on_create_if_role_not_allowed()
{ {
var actual = await sut.CanPublishInitialAsync(Schema, Mocks.FrontendUser(Role.Developer)); using var sutWorkflow = await GetWorkflowAsync();
var actual = sutWorkflow.CanPublishInitial(Mocks.FrontendUser(Role.Developer));
Assert.False(actual); Assert.False(actual);
} }
@ -136,19 +143,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_allow_if_transition_is_valid() public async Task Should_allow_if_transition_is_valid()
{ {
var content = CreateContent(Status.Draft, 2); using var sutWorkflow = await GetWorkflowAsync();
var actual = await sut.CanMoveToAsync(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor));
Assert.True(actual);
}
[Fact]
public async Task Should_allow_if_transition_is_valid_for_content()
{
var content = CreateContent(Status.Draft, 2); var content = CreateContent(Status.Draft, 2);
var actual = await sut.CanMoveToAsync(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.CanMoveTo(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor));
Assert.True(actual); Assert.True(actual);
} }
@ -156,49 +155,50 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_allow_transition_if_role_is_not_allowed() public async Task Should_not_allow_transition_if_role_is_not_allowed()
{ {
var content = CreateContent(Status.Draft, 2); using var sutWorkflow = await GetWorkflowAsync();
var actual = await sut.CanMoveToAsync(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Developer));
Assert.False(actual);
}
[Fact]
public async Task Should_allow_transition_if_role_is_allowed()
{
var content = CreateContent(Status.Draft, 2); var content = CreateContent(Status.Draft, 2);
var actual = await sut.CanMoveToAsync(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.CanMoveTo(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Developer));
Assert.True(actual); Assert.False(actual);
} }
[Fact] [Fact]
public async Task Should_not_allow_transition_if_data_not_valid() public async Task Should_not_allow_transition_if_data_not_valid()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Draft, 4); var content = CreateContent(Status.Draft, 4);
var actual = await sut.CanMoveToAsync(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.CanMoveTo(content, content.Status, Status.Published, Mocks.FrontendUser(Role.Editor));
Assert.False(actual); Assert.False(actual);
} }
[Fact] [Fact]
public async Task Should_be_able_to_update_published() public async Task Should_evaluate_reused_expression_per_content()
{ {
var content = CreateContent(Status.Published, 2); using var sutWorkflow = await GetWorkflowAsync();
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Developer)); var content1 = CreateContent(Status.Draft, 2);
var content2 = CreateContent(Status.Draft, 4);
Assert.True(actual); var user = Mocks.FrontendUser(Role.Editor);
Assert.True(sutWorkflow.CanMoveTo(content1, content1.Status, Status.Published, user));
Assert.False(sutWorkflow.CanMoveTo(content2, content2.Status, Status.Published, user));
Assert.True(sutWorkflow.CanMoveTo(content1, content1.Status, Status.Published, user));
} }
[Fact] [Fact]
public async Task Should_be_able_to_update_draft() public async Task Should_be_able_to_update_published()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Developer)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Developer));
Assert.True(actual); Assert.True(actual);
} }
@ -206,9 +206,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_be_able_to_update_archived() public async Task Should_not_be_able_to_update_archived()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Archived, 2); var content = CreateContent(Status.Archived, 2);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Developer)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Developer));
Assert.False(actual); Assert.False(actual);
} }
@ -216,9 +218,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_be_able_to_update_published_with_true_expression() public async Task Should_not_be_able_to_update_published_with_true_expression()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Owner)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Owner));
Assert.False(actual); Assert.False(actual);
} }
@ -226,9 +230,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_be_able_to_update_published_with_false_expression() public async Task Should_be_able_to_update_published_with_false_expression()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 1); var content = CreateContent(Status.Published, 1);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Owner)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Owner));
Assert.True(actual); Assert.True(actual);
} }
@ -236,9 +242,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_be_able_to_update_published_with_correct_roles() public async Task Should_not_be_able_to_update_published_with_correct_roles()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Editor));
Assert.False(actual); Assert.False(actual);
} }
@ -246,9 +254,11 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_be_able_to_update_published_with_incorrect_roles() public async Task Should_be_able_to_update_published_with_incorrect_roles()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 1); var content = CreateContent(Status.Published, 1);
var actual = await sut.CanUpdateAsync(content, content.Status, Mocks.FrontendUser(Role.Owner)); var actual = sutWorkflow.CanUpdate(content, content.Status, Mocks.FrontendUser(Role.Owner));
Assert.True(actual); Assert.True(actual);
} }
@ -256,6 +266,8 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_get_next_statuses_for_draft() public async Task Should_get_next_statuses_for_draft()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Draft, 2); var content = CreateContent(Status.Draft, 2);
var expected = new[] var expected = new[]
@ -263,7 +275,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Archived, StatusColors.Archived), new StatusInfo(Status.Archived, StatusColors.Archived),
}; };
var actual = await sut.GetNextAsync(content, content.Status, Mocks.FrontendUser(Role.Developer)); var actual = sutWorkflow.GetNext(content, content.Status, Mocks.FrontendUser(Role.Developer));
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -271,6 +283,8 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_limit_next_statuses_if_expression_does_not_evauate_to_true() public async Task Should_limit_next_statuses_if_expression_does_not_evauate_to_true()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Draft, 4); var content = CreateContent(Status.Draft, 4);
var expected = new[] var expected = new[]
@ -278,7 +292,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Archived, StatusColors.Archived), new StatusInfo(Status.Archived, StatusColors.Archived),
}; };
var actual = await sut.GetNextAsync(content, content.Status, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.GetNext(content, content.Status, Mocks.FrontendUser(Role.Editor));
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -286,6 +300,8 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_limit_next_statuses_if_role_is_not_allowed() public async Task Should_limit_next_statuses_if_role_is_not_allowed()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Draft, 2); var content = CreateContent(Status.Draft, 2);
var expected = new[] var expected = new[]
@ -294,14 +310,38 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Published, StatusColors.Published), new StatusInfo(Status.Published, StatusColors.Published),
}; };
var actual = await sut.GetNextAsync(content, content.Status, Mocks.FrontendUser(Role.Editor)); var actual = sutWorkflow.GetNext(content, content.Status, Mocks.FrontendUser(Role.Editor));
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
[Fact]
public async Task Should_not_reuse_next_statuses_of_conditional_step()
{
using var sutWorkflow = await GetWorkflowAsync();
var allowed = CreateContent(Status.Draft, 2);
var denied = CreateContent(Status.Draft, 4);
var user = Mocks.FrontendUser(Role.Editor);
sutWorkflow.GetNext(allowed, allowed.Status, user).Should().BeEquivalentTo(new[]
{
new StatusInfo(Status.Archived, StatusColors.Archived),
new StatusInfo(Status.Published, StatusColors.Published),
});
sutWorkflow.GetNext(denied, denied.Status, user).Should().BeEquivalentTo(new[]
{
new StatusInfo(Status.Archived, StatusColors.Archived),
});
}
[Fact] [Fact]
public async Task Should_get_next_statuses_for_archived() public async Task Should_get_next_statuses_for_archived()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Archived, 2); var content = CreateContent(Status.Archived, 2);
var expected = new[] var expected = new[]
@ -309,7 +349,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Draft, StatusColors.Draft), new StatusInfo(Status.Draft, StatusColors.Draft),
}; };
var actual = await sut.GetNextAsync(content, content.Status, null!); var actual = sutWorkflow.GetNext(content, content.Status, null!);
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -317,6 +357,8 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_get_next_statuses_for_published() public async Task Should_get_next_statuses_for_published()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var content = CreateContent(Status.Published, 2); var content = CreateContent(Status.Published, 2);
var expected = new[] var expected = new[]
@ -325,7 +367,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Draft, StatusColors.Draft), new StatusInfo(Status.Draft, StatusColors.Draft),
}; };
var actual = await sut.GetNextAsync(content, content.Status, null!); var actual = sutWorkflow.GetNext(content, content.Status, null!);
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -333,6 +375,8 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_return_all_statuses() public async Task Should_return_all_statuses()
{ {
using var sutWorkflow = await GetWorkflowAsync();
var expected = new[] var expected = new[]
{ {
new StatusInfo(Status.Archived, StatusColors.Archived), new StatusInfo(Status.Archived, StatusColors.Archived),
@ -340,7 +384,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Published, StatusColors.Published), new StatusInfo(Status.Published, StatusColors.Published),
}; };
var actual = await sut.GetAllAsync(Schema); var actual = sutWorkflow.GetAll();
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -348,13 +392,15 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_return_all_statuses_for_simple_schema_workflow() public async Task Should_return_all_statuses_for_simple_schema_workflow()
{ {
using var sutWorkflow = await GetWorkflowAsync(Schema.WithId(simpleSchemaId, "simple-schema"));
var expected = new[] var expected = new[]
{ {
new StatusInfo(Status.Draft, StatusColors.Draft), new StatusInfo(Status.Draft, StatusColors.Draft),
new StatusInfo(Status.Published, StatusColors.Published), new StatusInfo(Status.Published, StatusColors.Published),
}; };
var actual = await sut.GetAllAsync(Schema.WithId(simpleSchemaId, "simple-schema")); var actual = sutWorkflow.GetAll();
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -367,6 +413,8 @@ public class DynamicContentWorkflowTests : GivenContext
Workflows = Workflows.Empty, Workflows = Workflows.Empty,
}; };
using var sutWorkflow = await GetWorkflowAsync();
var expected = new[] var expected = new[]
{ {
new StatusInfo(Status.Archived, StatusColors.Archived), new StatusInfo(Status.Archived, StatusColors.Archived),
@ -374,7 +422,7 @@ public class DynamicContentWorkflowTests : GivenContext
new StatusInfo(Status.Published, StatusColors.Published), new StatusInfo(Status.Published, StatusColors.Published),
}; };
var actual = await sut.GetAllAsync(Schema); var actual = sutWorkflow.GetAll();
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
} }
@ -382,7 +430,9 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_validate_when_not_publishing() public async Task Should_not_validate_when_not_publishing()
{ {
var actual = await sut.ShouldValidateAsync(Schema, Status.Draft); using var sutWorkflow = await GetWorkflowAsync();
var actual = sutWorkflow.ShouldValidate(Status.Draft);
Assert.False(actual); Assert.False(actual);
} }
@ -390,12 +440,12 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_not_validate_when_publishing_but_not_enabled() public async Task Should_not_validate_when_publishing_but_not_enabled()
{ {
var schema = Schema with using var sutWorkflow = await GetWorkflowAsync(Schema with
{ {
Properties = new SchemaProperties { ValidateOnPublish = false }, Properties = new SchemaProperties { ValidateOnPublish = false },
}; });
var actual = await sut.ShouldValidateAsync(schema, Status.Published); var actual = sutWorkflow.ShouldValidate(Status.Published);
Assert.False(actual); Assert.False(actual);
} }
@ -403,12 +453,12 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_validate_when_publishing_and_enabled() public async Task Should_validate_when_publishing_and_enabled()
{ {
var schema = Schema with using var sutWorkflow = await GetWorkflowAsync(Schema with
{ {
Properties = new SchemaProperties { ValidateOnPublish = true }, Properties = new SchemaProperties { ValidateOnPublish = true },
}; });
var actual = await sut.ShouldValidateAsync(schema, Status.Published); var actual = sutWorkflow.ShouldValidate(Status.Published);
Assert.True(actual); Assert.True(actual);
} }
@ -416,24 +466,21 @@ public class DynamicContentWorkflowTests : GivenContext
[Fact] [Fact]
public async Task Should_validate_when_enabled_in_step() public async Task Should_validate_when_enabled_in_step()
{ {
var actual = await sut.ShouldValidateAsync(Schema, Status.Archived); using var sutWorkflow = await GetWorkflowAsync();
var actual = sutWorkflow.ShouldValidate(Status.Archived);
Assert.True(actual); Assert.True(actual);
} }
private EnrichedContent CreateContent(Status status, int value, bool simple = false) private ValueTask<IContentWorkflow> GetWorkflowAsync(Schema? schema = null)
{ {
var content = CreateContent(); return sut.GetWorkflowAsync(App, schema ?? Schema, CancellationToken);
}
if (simple)
{
content = content with
{
SchemaId = NamedId.Of(simpleSchemaId, "my-simple-schema"),
};
}
content = content with private EnrichedContent CreateContent(Status status, int value)
{
return CreateContent() with
{ {
Status = status, Status = status,
Data = Data =
@ -442,7 +489,5 @@ public class DynamicContentWorkflowTests : GivenContext
new ContentFieldData() new ContentFieldData()
.AddInvariant(value)), .AddInvariant(value)),
}; };
return content;
} }
} }

64
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps; using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
@ -16,11 +17,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries;
public class EnrichWithWorkflowsTests : GivenContext public class EnrichWithWorkflowsTests : GivenContext
{ {
private readonly IContentWorkflow workflow = A.Fake<IContentWorkflow>(); private readonly IContentWorkflow workflow = A.Fake<IContentWorkflow>();
private readonly IContentWorkflows workflows = A.Fake<IContentWorkflows>();
private readonly EnrichWithWorkflows sut; private readonly EnrichWithWorkflows sut;
public EnrichWithWorkflowsTests() public EnrichWithWorkflowsTests()
{ {
sut = new EnrichWithWorkflows(workflow); A.CallTo(() => workflows.GetWorkflowAsync(App, Schema, A<CancellationToken>._))
.Returns(workflow);
sut = new EnrichWithWorkflows(workflows);
} }
[Fact] [Fact]
@ -33,10 +38,10 @@ public class EnrichWithWorkflowsTests : GivenContext
new StatusInfo(Status.Published, StatusColors.Published), new StatusInfo(Status.Published, StatusColors.Published),
}; };
A.CallTo(() => workflow.GetNextAsync(content, content.Status, FrontendContext.UserPrincipal)) A.CallTo(() => workflow.GetNext(content, content.Status, FrontendContext.UserPrincipal))
.Returns(nexts); .Returns(nexts);
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Equal(nexts, content.NextStatuses); Assert.Equal(nexts, content.NextStatuses);
} }
@ -46,11 +51,11 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent() with { IsSingleton = true, Status = Status.Draft }; var content = CreateContent() with { IsSingleton = true, Status = Status.Draft };
await sut.EnrichAsync(FrontendContext, [content], null!, default); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), default);
Assert.Equal(Status.Published, content.NextStatuses?.Single().Status); Assert.Equal(Status.Published, content.NextStatuses?.Single().Status);
A.CallTo(() => workflow.GetNextAsync(content, A<Status>._, FrontendContext.UserPrincipal)) A.CallTo(() => workflow.GetNext(content, A<Status>._, FrontendContext.UserPrincipal))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -59,11 +64,11 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent() with { IsSingleton = true }; var content = CreateContent() with { IsSingleton = true };
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Empty(content.NextStatuses!); Assert.Empty(content.NextStatuses!);
A.CallTo(() => workflow.GetNextAsync(content, A<Status>._, FrontendContext.UserPrincipal)) A.CallTo(() => workflow.GetNext(content, A<Status>._, FrontendContext.UserPrincipal))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -72,10 +77,10 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent(); var content = CreateContent();
A.CallTo(() => workflow.GetInfoAsync(content, content.Status)) A.CallTo(() => workflow.GetInfo(content.Status))
.Returns(new StatusInfo(Status.Published, StatusColors.Published)); .Returns(new StatusInfo(Status.Published, StatusColors.Published));
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Equal(StatusColors.Published, content.StatusColor); Assert.Equal(StatusColors.Published, content.StatusColor);
} }
@ -85,10 +90,10 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent() with { NewStatus = Status.Archived }; var content = CreateContent() with { NewStatus = Status.Archived };
A.CallTo(() => workflow.GetInfoAsync(content, content.NewStatus!.Value)) A.CallTo(() => workflow.GetInfo(content.NewStatus!.Value))
.Returns(new StatusInfo(Status.Published, StatusColors.Archived)); .Returns(new StatusInfo(Status.Published, StatusColors.Archived));
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Equal(StatusColors.Archived, content.NewStatusColor); Assert.Equal(StatusColors.Archived, content.NewStatusColor);
} }
@ -98,10 +103,10 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent() with { ScheduleJob = ScheduleJob.Build(Status.Archived, User, Timestamp()) }; var content = CreateContent() with { ScheduleJob = ScheduleJob.Build(Status.Archived, User, Timestamp()) };
A.CallTo(() => workflow.GetInfoAsync(content, content.ScheduleJob.Status)) A.CallTo(() => workflow.GetInfo(content.ScheduleJob.Status))
.Returns(new StatusInfo(Status.Published, StatusColors.Archived)); .Returns(new StatusInfo(Status.Published, StatusColors.Archived));
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Equal(StatusColors.Archived, content.ScheduledStatusColor); Assert.Equal(StatusColors.Archived, content.ScheduledStatusColor);
} }
@ -111,10 +116,10 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent(); var content = CreateContent();
A.CallTo(() => workflow.GetInfoAsync(content, content.Status)) A.CallTo(() => workflow.GetInfo(content.Status))
.Returns(ValueTask.FromResult<StatusInfo?>(null!)); .Returns(null);
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.Equal(StatusColors.Draft, content.StatusColor); Assert.Equal(StatusColors.Draft, content.StatusColor);
} }
@ -124,10 +129,10 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent(); var content = CreateContent();
A.CallTo(() => workflow.CanUpdateAsync(content, content.Status, FrontendContext.UserPrincipal)) A.CallTo(() => workflow.CanUpdate(content, content.Status, FrontendContext.UserPrincipal))
.Returns(true); .Returns(true);
await sut.EnrichAsync(FrontendContext, [content], null!, CancellationToken); await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
Assert.True(content.CanUpdate); Assert.True(content.CanUpdate);
} }
@ -137,11 +142,28 @@ public class EnrichWithWorkflowsTests : GivenContext
{ {
var content = CreateContent(); var content = CreateContent();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithResolveFlow(false)), [content], null!, CancellationToken); await sut.EnrichAsync(ApiContext.Clone(b => b.WithResolveFlow(false)), [content], SchemaProvider(), CancellationToken);
Assert.False(content.CanUpdate); Assert.False(content.CanUpdate);
A.CallTo(() => workflow.CanUpdateAsync(content, A<Status>._, FrontendContext.UserPrincipal)) A.CallTo(() => workflow.CanUpdate(content, A<Status>._, FrontendContext.UserPrincipal))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact]
public async Task Should_resolve_workflow_once_per_schema()
{
var content1 = CreateContent();
var content2 = CreateContent();
await sut.EnrichAsync(FrontendContext, [content1, content2], SchemaProvider(), CancellationToken);
A.CallTo(() => workflows.GetWorkflowAsync(App, Schema, A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}
private ProvideSchema SchemaProvider()
{
return x => Task.FromResult((Schema, ResolvedComponents.Empty));
}
} }

1236
resolved.md

File diff suppressed because it is too large

100
todo.md

@ -1,100 +0,0 @@
# Backend Performance — Open
Analysis of `backend/src` (2131 C# files, ~187k LOC). Ordered by severity: expected
production impact × how hot the code path is.
Severity key: **S1** critical (can dominate request latency or take the process down),
**S2** high (measurable on every request in a common path), **S3** moderate (steady
overhead / allocation churn), **S4** low (worth fixing while nearby).
Item numbers are stable and never reused. Completed items move to
[resolved.md](resolved.md) keeping their number, so gaps in the sequence here are
expected — items **4**–**35** are closed and live there.
**Status: 3 open of 35 — items 1, 2 and 3, which are one root cause. The other 32 are in [resolved.md](resolved.md).**
---
## S1 — Critical
### 1. A fresh Jint `Engine` is constructed for every script evaluation
`backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs:144`
`CreateEngine` calls `new Engine(...)` on every `Execute` / `ExecuteAsync` /
`TransformAsync`. Building a Jint engine allocates a complete JS realm (global object,
`Object`/`Array`/`JSON`/`Math`/`RegExp` prototypes, intrinsics) plus runs every
registered `IJintExtension.Extend`. Script *parsing* is cached via `CacheParser`, but
engine construction — the expensive half — is not.
This is the root cause of items 2 and 3, which is why it ranks first.
**Fix:** pool engines (`ObjectPool<Engine>`) keyed by the option set, resetting globals
between uses; or hoist one engine per enrichment batch instead of per item.
---
### 2. Workflow enrichment runs one Jint engine per content *per transition*
`backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs:90,118`
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs:22,30,31`
`EnrichWithWorkflows` loops over every content and awaits `GetNextAsync` and
`CanUpdateAsync` sequentially. `GetNextAsync` loops over every transition and calls
`IsTrue`, which calls `scriptEngine.Evaluate` whenever the transition has an
expression — a new engine each time (item 1).
A frontend content list of 200 items with a workflow having 3 conditional transitions
executes **200 × (3 + 1) = 800 engine constructions** in one request, serially.
`GetWorkflowAsync` additionally re-scans `app.Workflows.Values` with
`SchemaIds.Contains(schemaId)` on every one of those calls.
**Fix:** cache the resolved `Workflow` per (appId, schemaId) for the batch; memoize
condition results per (transition, contentData); reuse one engine.
---
### 3. Query scripts execute one engine per content, serially
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs:57`
`foreach (var content in group) await TransformAsync(...)` — every content in the page
gets its own engine construction plus its own
`CancellationTokenSource.CreateLinkedTokenSource`. Any schema with a query script pays
this on every read.
**Fix:** same as item 1 — reuse the engine across the group; per-content state is
already isolated in `ContentScriptVars`.
---
## Suggested order of attack
Only **engine pooling (items 1–3)** is left. It is the largest single cost on the list and
also the most invasive change: it touches the security boundary of user-authored scripts,
because a pooled engine must not carry state from one script into the next.
**Profile before writing it.** The estimate that engine construction dominates a scripted
content list is read off the loops, not taken from a trace, and this is the one item where
the fix is expensive enough that being wrong about the size of the win would matter.
---
## Method / caveats
Findings come from static reading of the hot paths (content query + enrichment pipeline,
GraphQL execution, write/validation path, event consumers, HTTP pipeline, MongoDB and EF
repositories, asset serving, response/DTO construction) plus scripted scans for
sync-over-async, awaits inside loops, uncached `Regex`, and repeated LINQ materialisation.
**No profiling or benchmarking was run.** The ordering is a reasoned estimate of impact,
not measured data. Counts like "200 × 4 engine constructions" or "2000 link generations"
are derived from reading the loops, not observed. Confirm the expensive items with a
profiler against a representative workload before investing in the larger refactors.
Items 21–30 were added in a second pass over areas the first pass had not covered: the
EF data layer, asset serving and transformation, response DTO and link construction, the
full-text search path, and the remaining enrichment steps. Two candidates were dropped
during that pass after checking them: per-content permission checks (already memoized in
`Resources.Can`) and the lazily built static maps in `Adapt` (a benign race that at worst
builds the same dictionary twice).
Line numbers were verified against the working tree at the time of writing.
Loading…
Cancel
Save