diff --git a/backend/.editorconfig b/backend/.editorconfig index f88bc259fa..afd4c9dd83 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -172,4 +172,7 @@ dotnet_diagnostic.SA1602.severity = none dotnet_diagnostic.SA1615.severity = none # SA1623: Property summary documentation should match accessors -dotnet_diagnostic.SA1623.severity = none \ No newline at end of file +dotnet_diagnostic.SA1623.severity = none + +# xUnit1033: Test classes decorated with 'Xunit.IClassFixture' or 'Xunit.ICollectionFixture' should add a constructor argument of type TFixture +dotnet_diagnostic.xUnit1033.severity = none \ No newline at end of file diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 4040138420..a0b4ad69e8 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Fields", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Created", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Created By", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "By", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Updated", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Updated By", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "By", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Next Status", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Status", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Translation Status", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Average Translation Status", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Version", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "More", "schemas.tabScripts": "Scripts", "schemas.tabUI": "UI", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index ff2151e6b2..aa458b5321 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Des champs", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Créé", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Créé par", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "Par", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Identifiant", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Mis à jour", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Mis à jour par", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "Par", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Statut suivant", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Statut", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Statut de la traduction", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "État de traduction moyen", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Version", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "Plus", "schemas.tabScripts": "Scénarios", "schemas.tabUI": "interface utilisateur", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index e866631608..68a8e3d5cc 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Campi", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Creato", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Creato da", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "Da", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Modificato", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Modificato da", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "Da", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Stato successivo", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Stato", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Translation Status", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Average Translation Status", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Versione", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "Altro", "schemas.tabScripts": "Script", "schemas.tabUI": "UI", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 08154b5ad2..db4c22ee47 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Velden", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Gemaakt", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Gemaakt door", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "Door", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Bijgewerkt", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Bijgewerkt door", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "Door", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Volgende status", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Status", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Translation Status", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Average Translation Status", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Versie", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "Meer", "schemas.tabScripts": "Scripts", "schemas.tabUI": "UI", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index a7283bc0ce..55c1775f59 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Campos", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Criado", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Criado por", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "Por", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Actualizado", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Actualizado por", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "Por", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Próximo Estado", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Estado", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Estado da Tradução", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Estado médio da tradução", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Versão", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "Mais", "schemas.tabScripts": "Scripts", "schemas.tabUI": "UI", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index b9f4570239..9fd9c0339b 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -986,17 +986,29 @@ "schemas.tabFields": "字段", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "创建", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "创建者", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "By", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "更新", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "更新者", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "By", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "下一个状态", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "状态", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Translation Status", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Average Translation Status", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "版本", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "More", "schemas.tabScripts": "Scripts", "schemas.tabUI": "UI", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 4040138420..a0b4ad69e8 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -986,17 +986,29 @@ "schemas.tabFields": "Fields", "schemas.tabJson": "Json", "schemas.tableHeaders.created": "Created", + "schemas.tableHeaders.created_title": "Created", "schemas.tableHeaders.createdBy": "Created By", + "schemas.tableHeaders.createdBy_title": "Created By", "schemas.tableHeaders.createdByShort": "By", + "schemas.tableHeaders.createdByShort_title": "Created By (Avatar only)", "schemas.tableHeaders.id": "Id", + "schemas.tableHeaders.id_title": "Id", "schemas.tableHeaders.lastModified": "Updated", + "schemas.tableHeaders.lastModified_title": "Updated", "schemas.tableHeaders.lastModifiedBy": "Updated By", + "schemas.tableHeaders.lastModifiedBy_title": "Updated By", "schemas.tableHeaders.lastModifiedByShort": "By", + "schemas.tableHeaders.lastModifiedByShort_title": "Updated By (Avatar only)", "schemas.tableHeaders.nextStatus": "Next Status", + "schemas.tableHeaders.nextStatus_title": "Next Status", "schemas.tableHeaders.status": "Status", + "schemas.tableHeaders.status_title": "Status", "schemas.tableHeaders.translationStatus": "Translation Status", + "schemas.tableHeaders.translationStatus_title": "Translation Status (current Language)", "schemas.tableHeaders.translationStatusAverage": "Average Translation Status", + "schemas.tableHeaders.translationStatusAverage_title": "Translation Status (average across Languages)", "schemas.tableHeaders.version": "Version", + "schemas.tableHeaders.version_title": "Version", "schemas.tabMore": "More", "schemas.tabScripts": "Scripts", "schemas.tabUI": "UI", diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs index 51259bcfb9..a1f8a10402 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper; public static class JsonMapper { - private class JsonObjectInstance : ObjectInstance + private sealed class JsonObjectInstance : ObjectInstance { public JsonObjectInstance(Engine engine) : base(engine) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs index f953ba6f45..6eedf84fd8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs @@ -35,7 +35,7 @@ public static List ToIds(this JsValue? value) return ids; } - internal static ScriptExecutionContext ExtendAsync(this ScriptExecutionContext context, + internal static ScriptExecutionContext ExtendWithAsyncFunctions(this ScriptExecutionContext context, IEnumerable extensions) { foreach (var extension in extensions) @@ -46,7 +46,7 @@ public static List ToIds(this JsValue? value) return context; } - internal static ScriptExecutionContext Extend(this ScriptExecutionContext context, + internal static ScriptExecutionContext ExtendWithFunctions(this ScriptExecutionContext context, IEnumerable extensions) { foreach (var extension in extensions) @@ -57,7 +57,7 @@ public static List ToIds(this JsValue? value) return context; } - internal static ScriptExecutionContext Extend(this ScriptExecutionContext context, + internal static ScriptExecutionContext ExtendWithVariables(this ScriptExecutionContext context, ScriptVars vars, ScriptOptions options) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index dd38aa7fc5..a91690c6c2 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -53,10 +53,10 @@ public JintScriptEngine(IMemoryCache cache, IOptions options, combined.CancelAfter(timeoutExecution); var context = - CreateEngine(options, combined.Token) - .Extend(vars, options) - .Extend(extensions) - .ExtendAsync(extensions); + CreateEngine(options, combined.Token) + .ExtendWithVariables(vars, options) + .ExtendWithFunctions(extensions) + .ExtendWithAsyncFunctions(extensions); context.Engine.SetValue("complete", new Action(value => { @@ -65,7 +65,7 @@ public JintScriptEngine(IMemoryCache cache, IOptions options, var result = Execute(context, script); - return await context.CompleteAsync() ?? JsonMapper.Map(result); + return await context.WaitForCompletionAsync(() => JsonMapper.Map(result)); } catch (Exception ex) { @@ -79,6 +79,8 @@ public JintScriptEngine(IMemoryCache cache, IOptions options, Guard.NotNull(vars); Guard.NotNullOrEmpty(script); + var data = vars.Data!; + using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); try { @@ -87,13 +89,13 @@ public JintScriptEngine(IMemoryCache cache, IOptions options, var context = CreateEngine(options, combined.Token) - .Extend(vars, options) - .Extend(extensions) - .ExtendAsync(extensions); + .ExtendWithVariables(vars, options) + .ExtendWithFunctions(extensions) + .ExtendWithAsyncFunctions(extensions); context.Engine.SetValue("complete", new Action(_ => { - context.Complete(vars.Data!); + context.Complete(data!); })); context.Engine.SetValue("replace", new Action(() => @@ -111,7 +113,7 @@ public JintScriptEngine(IMemoryCache cache, IOptions options, Execute(context, script); - return await context.CompleteAsync() ?? vars.Data!; + return await context.WaitForCompletionAsync(() => data); } catch (Exception ex) { @@ -128,8 +130,8 @@ public JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = { var context = CreateEngine(options, default) - .Extend(vars, options) - .Extend(extensions); + .ExtendWithVariables(vars, options) + .ExtendWithFunctions(extensions); var result = Execute(context, script); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs index 66454bbdf8..fed2dc0431 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs @@ -21,17 +21,22 @@ protected ScriptExecutionContext(Engine engine) Engine = engine; } - public abstract JsValue Evaluate(Script program); + public abstract JsValue Evaluate(Script script); public abstract void Schedule(Func action); } public sealed class ScriptExecutionContext : ScriptExecutionContext, IScheduler { - private readonly TaskCompletionSource tcs = new TaskCompletionSource(); + private readonly TaskCompletionSource tcs = new TaskCompletionSource(); private readonly CancellationToken cancellationToken; private int pendingTasks = 1; + private sealed class CompletedValue + { + public T Value { get; init; } + } + public bool IsCompleted { get => tcs.Task.Status is TaskStatus.RanToCompletion or TaskStatus.Faulted; @@ -43,16 +48,23 @@ internal ScriptExecutionContext(Engine engine, CancellationToken cancellationTok this.cancellationToken = cancellationToken; } - public Task CompleteAsync() + public async Task WaitForCompletionAsync(Func fallback) { - TryComplete(default!); + TryComplete(); + + var result = await tcs.Task.WithCancellation(cancellationToken); - return tcs.Task.WithCancellation(cancellationToken); + if (result == null) + { + return fallback(); + } + + return result.Value; } public void Complete(T value) { - tcs.TrySetResult(value); + tcs.TrySetResult(new CompletedValue { Value = value }); } public override JsValue Evaluate(Script script) @@ -77,7 +89,7 @@ async Task ScheduleAsync() { await action(this, cancellationToken); - TryComplete(default!); + TryComplete(); } catch (Exception ex) { @@ -104,7 +116,7 @@ void IScheduler.Run(Action? action) action(); } - TryComplete(default!); + TryComplete(); } catch (Exception ex) { @@ -146,7 +158,7 @@ private void TryStart() Interlocked.Increment(ref pendingTasks); } - private void TryComplete(T result) + private void TryComplete(CompletedValue? result = null) { if (Interlocked.Decrement(ref pendingTasks) <= 0) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs index 93c09c13a2..e28608098e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs @@ -464,11 +464,6 @@ private void AddFunction(string? name, string? description) Add(JsonType.Function, name, description); } - private void AddObject(string? name, string? description) - { - Add(JsonType.Object, name, description); - } - private void AddString(string? name, string? description) { Add(JsonType.String, name, description); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/CustomFluidParser.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/CustomFluidParser.cs index e21d860693..d977704e57 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/CustomFluidParser.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Templates/CustomFluidParser.cs @@ -9,6 +9,8 @@ using Fluid.Ast; using Parlot.Fluent; +#pragma warning disable CA1822 // Mark members as static + namespace Squidex.Domain.Apps.Core.Templates; public sealed class CustomFluidParser : FluidParser diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index 070d7088df..a8ef6eff1c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -9,6 +9,7 @@ using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ExtractReferenceIds; +using Squidex.Domain.Apps.Entities.Apps.Repositories; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.DomainObject; using Squidex.Infrastructure; @@ -127,9 +128,10 @@ public ContentDomainObject.State ToState() return state; } - public static async Task CreatePublishedAsync(SnapshotWriteJob job, IAppProvider appProvider) + public static async Task CreatePublishedAsync(SnapshotWriteJob job, IAppProvider appProvider, + CancellationToken ct) { - var entity = await CreateContentAsync(job.Value.CurrentVersion.Data, job, appProvider); + var entity = await CreateContentAsync(job.Value.CurrentVersion.Data, job, appProvider, ct); entity.ScheduledAt = null; entity.ScheduleJob = null; @@ -138,9 +140,10 @@ public static async Task CreatePublishedAsync(SnapshotWriteJ return entity; } - public static async Task CreateCompleteAsync(SnapshotWriteJob job, IAppProvider appProvider) + public static async Task CreateCompleteAsync(SnapshotWriteJob job, IAppProvider appProvider, + CancellationToken ct) { - var entity = await CreateContentAsync(job.Value.Data, job, appProvider); + var entity = await CreateContentAsync(job.Value.Data, job, appProvider, ct); entity.ScheduledAt = job.Value.ScheduleJob?.DueTime; entity.ScheduleJob = job.Value.ScheduleJob; @@ -151,7 +154,8 @@ public static async Task CreateCompleteAsync(SnapshotWriteJo return entity; } - private static async Task CreateContentAsync(ContentData data, SnapshotWriteJob job, IAppProvider appProvider) + private static async Task CreateContentAsync(ContentData data, SnapshotWriteJob job, IAppProvider appProvider, + CancellationToken ct) { var entity = SimpleMapper.Map(job.Value, new MongoContentEntity()); @@ -162,20 +166,22 @@ private static async Task CreateContentAsync(ContentData dat entity.ReferencedIds ??= new HashSet(); entity.Version = job.NewVersion; - var (app, schema) = await appProvider.GetAppWithSchemaAsync(job.Value.AppId.Id, job.Value.SchemaId.Id, true); + var (app, schema) = await appProvider.GetAppWithSchemaAsync(entity.IndexedAppId, entity.IndexedSchemaId, true, ct); - if (schema?.SchemaDef != null && app != null) + if (app == null || schema == null) { - if (data.CanHaveReference()) - { - var components = await appProvider.GetComponentsAsync(schema); + return entity; + } - entity.Data.AddReferencedIds(schema.SchemaDef, entity.ReferencedIds, components); - } + if (data.CanHaveReference()) + { + var components = await appProvider.GetComponentsAsync(schema, ct: ct); - entity.TranslationStatus = TranslationStatus.Create(data, schema.SchemaDef, app.Languages); + entity.Data.AddReferencedIds(schema.SchemaDef, entity.ReferencedIds, components); } + entity.TranslationStatus = TranslationStatus.Create(data, schema.SchemaDef, app.Languages); + return entity; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 0490ce3b18..2dea57f78e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -133,13 +133,13 @@ public partial class MongoContentRepository : ISnapshotStore job, CancellationToken ct) { - var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider)); + var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider, ct)); await collectionComplete.UpsertAsync(entityJob, ct); } @@ -199,7 +199,7 @@ public partial class MongoContentRepository : ISnapshotStore job, CancellationToken ct) { - var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider)); + var entityJob = job.As(await MongoContentEntity.CreateCompleteAsync(job, appProvider, ct)); await collectionComplete.UpsertVersionedAsync(session, entityJob, ct); } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs index cbe813b90d..da077c3387 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; @@ -17,6 +18,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; public static class Extensions { + private static Dictionary propertyMap; + + public static IReadOnlyDictionary PropertyMap + { + get => propertyMap ??= + BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).AllMemberMaps + .Where(x => + x.MemberName != nameof(MongoContentEntity.DraftData) && + x.MemberName != nameof(MongoContentEntity.Data)) + .ToDictionary( + x => x.MemberName, + x => x.ElementName, + StringComparer.OrdinalIgnoreCase); + } + public sealed class StatusOnly { [BsonId] @@ -56,7 +72,7 @@ public static bool IsSatisfiedByIndex(this ClrQuery query) query.Sort[1].Order == SortOrder.Ascending; } - public static async Task> QueryContentsAsync(this IMongoCollection collection, FilterDefinition filter, ClrQuery query, + public static async Task> QueryContentsAsync(this IMongoCollection collection, FilterDefinition filter, ClrQuery query, Q q, CancellationToken ct) { if (query.Skip > 0 && !query.IsSatisfiedByIndex()) @@ -98,6 +114,7 @@ await collection.Aggregate() .QuerySkip(query) .QueryLimit(query) .Lookup(collection, x => x.Id, x => x.DocumentId, x => x.Joined) + .SelectFields(q.Fields) .ToListAsync(ct); return joined.Select(x => x.Joined[0]).ToList(); @@ -108,6 +125,7 @@ await collection.Aggregate() .QuerySort(query) .QuerySkip(query) .QueryLimit(query) + .SelectFields(q.Fields) .ToListRandomAsync(collection, query.Random, ct); return await result; @@ -126,4 +144,41 @@ await collection.Aggregate() .Include(x => x.Status)) .ToListAsync(ct); } + + public static IFindFluent SelectFields(this IFindFluent find, IEnumerable? fields) + { + return find.Project(BuildProjection(fields)); + } + + public static IAggregateFluent SelectFields(this IAggregateFluent find, IEnumerable? fields) + { + return find.Project(BuildProjection(fields)); + } + + private static ProjectionDefinition BuildProjection(IEnumerable? fields) + { + var projector = Builders.Projection; + var projections = new List>(); + + if (fields?.Any() == true) + { + var dataField = Field.Of(x => nameof(x.Data)); + + foreach (var field in fields) + { + projections.Add(projector.Include($"{dataField}.{field}")); + } + + foreach (var field in PropertyMap.Values) + { + projections.Add(projector.Include(field)); + } + } + else + { + projections.Add(projector.Exclude(Field.Of(x => nameof(x.DraftData)))); + } + + return projector.Combine(projections); + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs index 4fb7419093..08aa4ce383 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs @@ -19,7 +19,7 @@ public sealed class QueryAsStream : OperationBase { var filter = CreateFilter(appId, schemaIds); - using (var cursor = await Collection.Find(filter).ToCursorAsync(ct)) + using (var cursor = await Collection.Find(filter).SelectFields(null).ToCursorAsync(ct)) { while (await cursor.MoveNextAsync(ct)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs index ee5df6ef43..05783a0c0f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs @@ -19,7 +19,7 @@ internal sealed class QueryById : OperationBase { var filter = Filter.Eq(x => x.DocumentId, DomainId.Combine(schema.AppId, id)); - var contentEntity = await Collection.Find(filter).FirstOrDefaultAsync(ct); + var contentEntity = await Collection.Find(filter).SelectFields(null).FirstOrDefaultAsync(ct); if (contentEntity == null || contentEntity.IndexedSchemaId != schema.Id) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs index 3168ee4346..22b0d3edab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs @@ -50,7 +50,7 @@ internal sealed class QueryByIds : OperationBase // Create a filter from the Ids and ensure that the content ids match to the schema IDs. var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), q.Ids.ToHashSet(), query.Filter); - var contentEntities = await FindContentsAsync(query, filter, ct); + var contentEntities = await FindContentsAsync(query, filter, q, ct); var contentTotal = (long)contentEntities.Count; if (contentTotal >= query.Take || query.Skip > 0) @@ -68,7 +68,7 @@ internal sealed class QueryByIds : OperationBase return ResultList.Create(contentTotal, contentEntities); } - private async Task> FindContentsAsync(ClrQuery query, FilterDefinition filter, + private async Task> FindContentsAsync(ClrQuery query, FilterDefinition filter, Q q, CancellationToken ct) { var result = @@ -76,6 +76,7 @@ internal sealed class QueryByIds : OperationBase .QuerySort(query) .QuerySkip(query) .QueryLimit(query) + .SelectFields(q.Fields) .ToListRandomAsync(Collection, query.Random, ct); return await result; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index 9d637ad2fb..3c002cba5a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs @@ -64,7 +64,7 @@ public override IEnumerable> CreateIndexes( var (filter, isDefault) = CreateFilter(app.Id, schemas.Select(x => x.Id), query, q.Reference, q.CreatedBy); - var contentEntities = await Collection.QueryContentsAsync(filter, query, ct); + var contentEntities = await Collection.QueryContentsAsync(filter, query, q, ct); var contentTotal = (long)contentEntities.Count; if (contentTotal >= query.Take || query.Skip > 0) @@ -96,7 +96,7 @@ public override IEnumerable> CreateIndexes( // Default means that no other filters are applied and we only query by app and schema. var (filter, isDefault) = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), query, q.Reference, q.CreatedBy); - var contentEntities = await Collection.QueryContentsAsync(filter, query, ct); + var contentEntities = await Collection.QueryContentsAsync(filter, query, q, ct); var contentTotal = (long)contentEntities.Count; if (contentTotal >= query.Take || query.Skip > 0) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs index df2c1cf3b8..2210a6b05e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs @@ -88,7 +88,7 @@ public Task> GetCollectionAsync(DomainId ap var filter = CreateFilter(query, q.Reference, q.CreatedBy); var contentCollection = await GetCollectionAsync(schema.AppId.Id, schema.Id); - var contentEntities = await contentCollection.QueryContentsAsync(filter, query, ct); + var contentEntities = await contentCollection.QueryContentsAsync(filter, query, q, ct); var contentTotal = (long)contentEntities.Count; if (contentTotal >= query.Take || query.Skip > 0) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs index 429cadf7a5..6bbf1fe8af 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrers.cs @@ -40,7 +40,7 @@ await Collection.Find(filter).Only(x => x.Id) { var filter = BuildFilter(appId, reference); - using (var cursor = await Collection.Find(filter).Limit(take).ToCursorAsync(ct)) + using (var cursor = await Collection.Find(filter).Limit(take).SelectFields(null).ToCursorAsync(ct)) { while (await cursor.MoveNextAsync(ct)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs index 3d7c1d2762..174f101774 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs @@ -33,7 +33,7 @@ public override IEnumerable> CreateIndexes( { var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), q.ScheduledFrom!.Value, q.ScheduledTo!.Value); - var contentEntities = await Collection.Find(filter).Limit(100).ToListAsync(ct); + var contentEntities = await Collection.Find(filter).Limit(100).SelectFields(q.Fields).ToListAsync(ct); var contentTotal = (long)contentEntities.Count; return ResultList.Create(contentTotal, contentEntities); @@ -42,7 +42,7 @@ public override IEnumerable> CreateIndexes( public IAsyncEnumerable QueryAsync(Instant now, CancellationToken ct) { - var find = Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true).Not(x => x.Data); + var find = Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true).Not(x => x.Data, x => x.DraftData); return find.ToAsyncEnumerable(ct); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 93a5bdf9dc..c067c69b27 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -213,6 +213,11 @@ public async Task> GetUserTeamsAsync(string userId, Cancellati return rules.Find(x => x.Id == id); } + public void RegisterAppForLocalContext(DomainId appId, IAppEntity app) + { + localCache.Add(AppCacheKey(appId), app); + } + public async Task GetOrCreate(object key, Func> creator) where T : class { if (localCache.TryGetValue(key, out var value)) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index 9ad459b6b8..85d1a9ebce 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -23,8 +23,10 @@ public sealed class BackupApps : IBackupHandler private const string AvatarFile = "Avatar.image"; private readonly Rebuilder rebuilder; private readonly IAppImageStore appImageStore; + private readonly IAppProvider appProvider; private readonly IAppsIndex appsIndex; private readonly IAppUISettings appUISettings; + private AppDomainObject? app; private string? appReservation; public string Name { get; } = "Apps"; @@ -32,11 +34,13 @@ public sealed class BackupApps : IBackupHandler public BackupApps( Rebuilder rebuilder, IAppImageStore appImageStore, + IAppProvider appProvider, IAppsIndex appsIndex, IAppUISettings appUISettings) { this.appsIndex = appsIndex; this.appImageStore = appImageStore; + this.appProvider = appProvider; this.appUISettings = appUISettings; this.rebuilder = rebuilder; } @@ -69,18 +73,12 @@ public sealed class BackupApps : IBackupHandler switch (@event.Payload) { case AppCreated appCreated: - { - await ReserveAppAsync(context.AppId, appCreated.Name, ct); - - break; - } + await ReserveAppAsync(context.AppId, appCreated.Name, ct); + break; case AppImageUploaded: - { - await ReadAssetAsync(context.AppId, context.Reader, ct); - - break; - } + await ReadAssetAsync(context.AppId, context.Reader, ct); + break; case AppContributorAssigned contributorAssigned: { @@ -110,6 +108,21 @@ public sealed class BackupApps : IBackupHandler public async Task RestoreAsync(RestoreContext context, CancellationToken ct) + { + await RestoreAppTemporarilyAsync(context, ct); + await RestoreSettingsAsync(context, ct); + } + + private async Task RestoreAppTemporarilyAsync(RestoreContext context, + CancellationToken ct) + { + app = await rebuilder.RebuildStateAsync(context.AppId, ct); + + // The app is only available in the context of the restore, so there is nothing to clear. + appProvider.RegisterAppForLocalContext(context.AppId, app!.Snapshot); + } + + private async Task RestoreSettingsAsync(RestoreContext context, CancellationToken ct) { var json = await context.Reader.ReadJsonAsync(SettingsFile, ct); @@ -134,7 +147,10 @@ public async Task CleanupRestoreErrorAsync(DomainId appId) public async Task CompleteRestoreAsync(RestoreContext context, string appName) { - await rebuilder.InsertManyAsync(Enumerable.Repeat(context.AppId, 1), 1, default); + if (app != null) + { + await app.RebuildStateAsync(); + } await appsIndex.RemoveReservationAsync(appReservation); } @@ -144,6 +160,7 @@ public async Task CompleteRestoreAsync(RestoreContext context, string appName) { try { + // Ignore errors when writing assets, because they are not that relevant. await using (var stream = await writer.OpenBlobAsync(AvatarFile, ct)) { await appImageStore.DownloadAsync(appId, stream, ct); @@ -159,6 +176,7 @@ await using (var stream = await writer.OpenBlobAsync(AvatarFile, ct)) { try { + // Ignore errors when writing assets, because they are not that relevant. await using (var stream = await reader.OpenBlobAsync(AvatarFile, ct)) { await appImageStore.UploadAsync(appId, stream, ct); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs index 9e295ed373..f3e36d6fa3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetHeaders.cs @@ -9,15 +9,15 @@ namespace Squidex.Domain.Apps.Entities.Assets; public static class AssetHeaders { - public const string HeaderNoEnrichment = "X-NoAssetEnrichment"; + public const string NoEnrichment = "X-NoAssetEnrichment"; public static bool ShouldSkipAssetEnrichment(this Context context) { - return context.Headers.ContainsKey(HeaderNoEnrichment); + return context.Headers.ContainsKey(NoEnrichment); } public static ICloneBuilder WithoutAssetEnrichment(this ICloneBuilder builder, bool value = true) { - return builder.WithBoolean(HeaderNoEnrichment, value); + return builder.WithBoolean(NoEnrichment, value); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupHandlerFactory.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupHandlerFactory.cs index f479fdc69b..f43284d437 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupHandlerFactory.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupHandlerFactory.cs @@ -20,6 +20,6 @@ public DefaultBackupHandlerFactory(IServiceProvider serviceProvider) public IEnumerable CreateMany() { - return serviceProvider.GetRequiredService>(); + return serviceProvider.GetRequiredService>().OrderBy(x => x.Order); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs index f90739351c..ea1d04a5a8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs @@ -14,6 +14,8 @@ public interface IBackupHandler { string Name { get; } + int Order => 0; + public Task RestoreEventAsync(Envelope @event, RestoreContext context, CancellationToken ct) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/StreamMapper.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/StreamMapper.cs index 67a38a67b2..51efe18da2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/StreamMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/StreamMapper.cs @@ -28,7 +28,7 @@ public StreamMapper(RestoreContext context) { Guard.NotNullOrEmpty(stream); - var typeIndex = stream.IndexOf("-", StringComparison.Ordinal); + var typeIndex = stream.IndexOf('-', StringComparison.Ordinal); var typeName = stream[..typeIndex]; var id = DomainId.Create(stream[(typeIndex + 1)..]); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs index cd43c464e4..7015b4d30a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -31,6 +31,8 @@ public sealed class BackupContents : IBackupHandler public string Name { get; } = "Contents"; + public int Order => int.MaxValue; + public sealed class Urls { public string Assets { get; set; } @@ -190,7 +192,6 @@ private void ReplaceAssetUrl(List source) CancellationToken ct) { var ids = contentIdsBySchemaId.Values.SelectMany(x => x); - if (ids.Any()) { await rebuilder.InsertManyAsync(ids, BatchSize, ct); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs index e7ccca8a6c..70ef174222 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHeaders.cs @@ -17,17 +17,19 @@ public static class ContentHeaders { private static readonly char[] Separators = { ',', ';' }; + public const string Fields = "X-Fields"; public const string Flatten = "X-Flatten"; public const string Languages = "X-Languages"; public const string NoCleanup = "X-NoCleanup"; public const string NoEnrichment = "X-NoEnrichment"; public const string NoResolveLanguages = "X-NoResolveLanguages"; public const string ResolveFlow = "X-ResolveFlow"; - public const string ResolveUrls = "X-Resolve-Urls"; + public const string ResolveUrls = "X-ResolveUrls"; public const string Unpublished = "X-Unpublished"; public static void AddCacheHeaders(this Context context, IRequestCache cache) { + cache.AddHeader(Fields); cache.AddHeader(Flatten); cache.AddHeader(Languages); cache.AddHeader(NoCleanup); @@ -128,25 +130,33 @@ public static ICloneBuilder WithAssetUrlsToResolve(this ICloneBuilder builder, I return builder.WithStrings(ResolveUrls, fieldNames); } - public static IEnumerable LanguageList(this Context context) + public static HashSet? FieldsList(this Context context) { - if (context.Headers.TryGetValue(Languages, out var value)) + if (context.Headers.TryGetValue(Fields, out var value)) { - var languages = new HashSet(); + return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + } - foreach (var iso2Code in value.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) - { - languages.Add(iso2Code); - } + return null; + } - return languages; + public static ICloneBuilder WithFields(this ICloneBuilder builder, IEnumerable fields) + { + return builder.WithStrings(Fields, fields); + } + + public static HashSet LanguagesList(this Context context) + { + if (context.Headers.TryGetValue(Languages, out var value)) + { + return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => (Language)x).ToHashSet(); } - return Enumerable.Empty(); + return new HashSet(); } - public static ICloneBuilder WithLanguages(this ICloneBuilder builder, IEnumerable fieldNames) + public static ICloneBuilder WithLanguages(this ICloneBuilder builder, IEnumerable languages) { - return builder.WithStrings(Languages, fieldNames); + return builder.WithStrings(Languages, languages); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index ae32af7757..f6b7517bd2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -9,9 +9,10 @@ using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Infrastructure; -using Squidex.Infrastructure.Json.Objects; using Squidex.Shared.Users; +#pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections + namespace Squidex.Domain.Apps.Entities.Contents.GraphQL; public sealed class GraphQLExecutionContext : QueryExecutionContext @@ -36,7 +37,8 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext Context = context.Clone(b => b .WithoutCleanup() - .WithoutContentEnrichment()); + .WithoutContentEnrichment() + .WithoutAssetEnrichment()); } public async ValueTask FindUserAsync(RefToken refToken, @@ -54,37 +56,24 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext } } - public async Task FindAssetAsync(DomainId id, + public async Task GetAssetAsync(DomainId id, TimeSpan cacheDuration, CancellationToken ct) { - var dataLoader = GetAssetsLoader(); + var assets = await GetAssetsAsync(new List { id }, cacheDuration, ct); + var asset = assets.FirstOrDefault(); - return await dataLoader.LoadAsync(id).GetResultAsync(ct); + return asset; } - public async Task FindContentAsync(DomainId schemaId, DomainId id, + public async Task GetContentAsync(DomainId schemaId, DomainId id, HashSet? fields, TimeSpan cacheDuration, CancellationToken ct) { - var dataLoader = GetContentsLoader(); - - var content = await dataLoader.LoadAsync(id).GetResultAsync(ct); - - if (content?.SchemaId.Id != schemaId) - { - content = null; - } + var contents = await GetContentsAsync(new List { id }, fields, cacheDuration, ct); + var content = contents.FirstOrDefault(x => x.SchemaId.Id == schemaId); return content; } - public Task> GetReferencedAssetsAsync(JsonValue value, TimeSpan cacheDuration, - CancellationToken ct) - { - var ids = ParseIds(value); - - return GetAssetsAsync(ids, cacheDuration, ct); - } - public async Task> GetAssetsAsync(List? ids, TimeSpan cacheDuration, CancellationToken ct) { @@ -113,15 +102,7 @@ async Task> LoadAsync(IEnumerable return await LoadAsync(ids); } - public Task> GetReferencedContentsAsync(JsonValue value, TimeSpan cacheDuration, - CancellationToken ct) - { - var ids = ParseIds(value); - - return GetContentsAsync(ids, cacheDuration, ct); - } - - public async Task> GetContentsAsync(List? ids, TimeSpan cacheDuration, + public async Task> GetContentsAsync(List? ids, HashSet? fields, TimeSpan cacheDuration, CancellationToken ct) { if (ids == null || ids.Count == 0) @@ -129,24 +110,23 @@ async Task> LoadAsync(IEnumerable return EmptyContents; } - async Task> LoadAsync(IEnumerable ids) - { - var result = await GetContentsLoader().LoadAsync(ids).GetResultAsync(ct); - - return result?.NotNull().ToList() ?? EmptyContents; - } - - if (cacheDuration > TimeSpan.Zero) + if (cacheDuration > TimeSpan.Zero || fields == null) { var contents = await ContentCache.CacheOrQueryAsync(ids, async pendingIds => { - return await LoadAsync(pendingIds); + var result = await GetContentsLoader().LoadAsync(ids).GetResultAsync(ct); + + return result?.NotNull().ToList() ?? EmptyContents; }, cacheDuration); return contents.ToList(); } + else + { + var contents = await GetContentsLoaderWithFields().LoadAsync(ids.Select(x => (x, fields))).GetResultAsync(ct); - return await LoadAsync(ids); + return contents?.NotNull().ToList() ?? EmptyContents; + } } private IDataLoader GetAssetsLoader() @@ -165,46 +145,33 @@ async Task> LoadAsync(IEnumerable(nameof(GetContentsLoader), async (batch, ct) => { - var result = await QueryContentsByIdsAsync(new List(batch), ct); + var result = await QueryContentsByIdsAsync(batch, null, ct); return result.ToDictionary(x => x.Id); }); } - private IDataLoader GetUserLoader() + private IDataLoader<(DomainId Id, HashSet Fields), IEnrichedContentEntity> GetContentsLoaderWithFields() { - return dataLoaders.Context!.GetOrAddBatchLoader(nameof(GetUserLoader), + return dataLoaders.Context!.GetOrAddBatchLoader<(DomainId Id, HashSet Fields), IEnrichedContentEntity>(nameof(GetContentsLoader), async (batch, ct) => { - var result = await Resolve().QueryManyAsync(batch.ToArray(), ct); + var fields = batch.SelectMany(x => x.Fields).ToHashSet(); - return result; + var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct); + + return result.ToDictionary(x => (x.Id, fields)); }); } - private static List? ParseIds(JsonValue value) + private IDataLoader GetUserLoader() { - try - { - List? result = null; - - if (value.Value is JsonArray a) + return dataLoaders.Context!.GetOrAddBatchLoader(nameof(GetUserLoader), + async (batch, ct) => { - foreach (var item in a) - { - if (item.Value is string id) - { - result ??= new List(); - result.Add(DomainId.Create(id)); - } - } - } - - return result; - } - catch - { - return null; - } + var result = await Resolve().QueryManyAsync(batch.ToArray(), ct); + + return result; + }); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs index 0edcf5246f..d923af5b37 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs @@ -7,6 +7,7 @@ using GraphQL.Types; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; +using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; @@ -79,7 +80,7 @@ private void AddContentQuery(Builder builder) { var unionType = builder.GetContentUnion("AllContents", null); - if (!unionType.HasType) + if (unionType.SchemaTypes.Count == 0) { return; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs index 9769ec3568..5dfa5ce83d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs @@ -61,7 +61,8 @@ public static class Find { var assetId = fieldContext.GetArgument("id"); - return await context.FindAssetAsync(assetId, + return await context.GetAssetAsync(assetId, + fieldContext.CacheDuration(), fieldContext.CancellationToken); }); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs index 4e4f51e187..af4e624666 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs @@ -48,6 +48,8 @@ static Builder() public IInterfaceGraphType ComponentInterface { get; } = new ComponentInterfaceGraphType(); + public List Schemas { get; private set; } + public Builder(IAppEntity app, GraphQLOptions options) { partitionResolver = app.PartitionResolver(); @@ -64,9 +66,9 @@ public GraphQLSchema BuildSchema(IEnumerable schemas) allSchemas.AddRange(SchemaInfo.Build(schemas, typeNames).Where(x => x.Fields.Count > 0)); // Only published normal schemas (not components are used for entities). - var schemaInfos = allSchemas.Where(x => x.Schema.SchemaDef.IsPublished && x.Schema.SchemaDef.Type != SchemaType.Component).ToList(); + Schemas = allSchemas.Where(x => x.Schema.SchemaDef.IsPublished && x.Schema.SchemaDef.Type != SchemaType.Component).ToList(); - foreach (var schemaInfo in schemaInfos) + foreach (var schemaInfo in Schemas) { var contentType = new ContentGraphType(schemaInfo); @@ -74,7 +76,7 @@ public GraphQLSchema BuildSchema(IEnumerable schemas) contentResultTypes[schemaInfo] = new ContentResultGraphType(contentType, schemaInfo); } - foreach (var schemaInfo in allSchemas) + foreach (var schemaInfo in Schemas) { var componentType = new ComponentGraphType(schemaInfo); @@ -83,17 +85,18 @@ public GraphQLSchema BuildSchema(IEnumerable schemas) var newSchema = new GraphQLSchema { - Query = new ApplicationQueries(this, schemaInfos) + Query = new ApplicationQueries(this, Schemas) }; newSchema.RegisterType(ComponentInterface); newSchema.RegisterType(ContentInterface); - newSchema.Directives.Register(SharedTypes.MemoryCacheDirective); + newSchema.Directives.Register(SharedTypes.CacheDirective); + newSchema.Directives.Register(SharedTypes.OptimizeFieldQueriesDirective); - if (schemaInfos.Any()) + if (Schemas.Any()) { - var mutations = new ApplicationMutations(this, schemaInfos); + var mutations = new ApplicationMutations(this, Schemas); if (mutations.Fields.Count > 0) { @@ -108,7 +111,7 @@ public GraphQLSchema BuildSchema(IEnumerable schemas) foreach (var (schemaInfo, contentType) in contentTypes) { - contentType.Initialize(this, schemaInfo, schemaInfos); + contentType.Initialize(this, schemaInfo, Schemas); } foreach (var (schemaInfo, componentType) in componentTypes) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index 04905ade62..5b24d50419 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -8,12 +8,17 @@ using GraphQL; using GraphQL.Resolvers; using GraphQL.Types; +using GraphQLParser; +using GraphQLParser.AST; using NodaTime; +using Squidex.CLI.Commands.Models; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives; using Squidex.Infrastructure; using Squidex.Shared; @@ -79,18 +84,20 @@ public static class Find public static readonly IFieldResolver Resolver = Resolvers.Async(async (_, fieldContext, context) => { var contentId = fieldContext.GetArgument("id"); - var contentSchema = fieldContext.FieldDefinition.SchemaId(); - var version = fieldContext.GetArgument("version"); + var contentSchemaId = fieldContext.FieldDefinition.SchemaId(); + var contentVersion = fieldContext.GetArgument("version"); - if (version >= 0) + if (contentVersion >= 0) { - return await context.FindContentAsync(contentSchema, contentId, version.Value, + return await context.FindContentAsync(contentSchemaId.ToString(), contentId, contentVersion.Value, fieldContext.CancellationToken); } else { - return await context.FindContentAsync(DomainId.Create(contentSchema), contentId, + return await context.GetContentAsync(contentSchemaId, contentId, + fieldContext.FieldNames(), + fieldContext.CacheDuration(), fieldContext.CancellationToken); } }); @@ -100,19 +107,22 @@ public static class QueryByIds { public static readonly QueryArguments Arguments = new QueryArguments { - new QueryArgument(Scalars.Strings) + new QueryArgument(Scalars.NonNullStrings) { Name = "ids", Description = FieldDescriptions.EntityIds, - DefaultValue = null + DefaultValue = null, } }; public static readonly IFieldResolver Resolver = Resolvers.Async(async (_, fieldContext, context) => { - var contentIds = fieldContext.GetArgument("ids"); + var ids = fieldContext.GetArgument("ids").ToList(); - return await context.QueryContentsByIdsAsync(contentIds, fieldContext.CancellationToken); + return await context.GetContentsAsync(ids, + fieldContext.FieldNames(), + fieldContext.CacheDuration(), + fieldContext.CancellationToken); }); } @@ -156,9 +166,12 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query).WithoutTotal(); + var q = Q.Empty + .WithODataQuery(query) + .WithoutTotal() + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); @@ -166,9 +179,11 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query); + var q = Q.Empty + .WithODataQuery(query) + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); @@ -176,9 +191,13 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query).WithReference(source.Id).WithoutTotal(); + var q = Q.Empty + .WithODataQuery(query) + .WithReference(source.Id) + .WithoutTotal() + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); @@ -186,9 +205,12 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query).WithReference(source.Id); + var q = Q.Empty + .WithODataQuery(query) + .WithReference(source.Id) + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); @@ -196,9 +218,13 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query).WithReferencing(source.Id).WithoutTotal(); + var q = Q.Empty + .WithODataQuery(query) + .WithReferencing(source.Id) + .WithoutTotal() + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); @@ -206,9 +232,12 @@ public static class QueryOrReferencing { var query = fieldContext.BuildODataQuery(); - var q = Q.Empty.WithODataQuery(query).WithReferencing(source.Id); + var q = Q.Empty + .WithODataQuery(query) + .WithReferencing(source.Id) + .WithFields(fieldContext.FieldNames()); - return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId().ToString(), q, fieldContext.CancellationToken); }); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs index 67edf29e9a..41c5adc3ac 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ExtractReferenceIds; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Infrastructure.Json.Objects; @@ -20,20 +21,21 @@ internal static class ContentFields { public static readonly IFieldResolver ResolveStringFieldAssets = Resolvers.Async(async (value, fieldContext, context) => { - var cacheDuration = fieldContext.CacheDuration(); - var ids = context.Resolve().GetEmbeddedAssetIds(value).ToList(); - return await context.GetAssetsAsync(ids, cacheDuration, fieldContext.CancellationToken); + return await context.GetAssetsAsync(ids, + fieldContext.CacheDuration(), + fieldContext.CancellationToken); }); public static readonly IFieldResolver ResolveStringFieldContents = Resolvers.Async(async (value, fieldContext, context) => { - var cacheDuration = fieldContext.CacheDuration(); - var ids = context.Resolve().GetEmbeddedContentIds(value).ToList(); - return await context.GetContentsAsync(ids, cacheDuration, fieldContext.CancellationToken); + return await context.GetContentsAsync(ids, + fieldContext.FieldNames(), + fieldContext.CacheDuration(), + fieldContext.CancellationToken); }); public static readonly FieldType Id = new FieldType diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs index adc96ec0ba..ec7c8e0294 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs @@ -8,16 +8,22 @@ using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; internal sealed class ContentGraphType : ObjectGraphType { + // We need the schema identity at runtime. + public DomainId SchemaId { get; set; } + public ContentGraphType(SchemaInfo schemaInfo) { // The name is used for equal comparison. Therefore it is important to treat it as readonly. Name = schemaInfo.ContentType; + + SchemaId = schemaInfo.Schema.Id; } public void Initialize(Builder builder, SchemaInfo schemaInfo, IEnumerable allSchemas) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentUnionGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentUnionGraphType.cs index b80de26e59..a13955b9e3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentUnionGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentUnionGraphType.cs @@ -15,7 +15,8 @@ internal sealed class ContentUnionGraphType : UnionGraphType { private readonly Dictionary types = new Dictionary(); - public bool HasType => types.Count > 0; + // We need the schema identity at runtime. + public IReadOnlyDictionary SchemaTypes => types; public ContentUnionGraphType(Builder builder, string name, ReadonlyList? schemaIds) { @@ -42,7 +43,7 @@ public ContentUnionGraphType(Builder builder, string name, ReadonlyList 0) { foreach (var type in types) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/DataInputGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/DataInputGraphType.cs index aa2b5e26f0..b9761b5d4a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/DataInputGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/DataInputGraphType.cs @@ -74,7 +74,7 @@ static ContentFieldData ToFieldData(IDictionary source, IComplex { if (source.TryGetValue(field.Name, out var value)) { - if (value is IEnumerable list && field.ResolvedType?.Flatten() is IComplexGraphType nestedType) + if (value is IEnumerable list && field.ResolvedType?.InnerType() is IComplexGraphType nestedType) { var array = new JsonArray(list.Count()); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs index 9c9c0d828e..af69a617b8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EmbeddableStringGraphType.cs @@ -50,7 +50,7 @@ public EmbeddableStringGraphType(Builder builder, FieldInfo fieldInfo, StringFie { var union = builder.GetContentUnion(fieldInfo.UnionReferenceType, schemaIds); - if (!union.HasType) + if (union.SchemaTypes.Count == 0) { return default; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs index 54601c211d..93189e8b82 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs @@ -100,16 +100,21 @@ internal sealed class FieldVisitor : IFieldVisitor private static readonly IFieldResolver Assets = CreateAsyncValueResolver((value, fieldContext, context) => { - var cacheDuration = fieldContext.CacheDuration(); + var ids = value.AsIds(); - return context.GetReferencedAssetsAsync(value, cacheDuration, fieldContext.CancellationToken); + return context.GetAssetsAsync(ids, + fieldContext.CacheDuration(), + fieldContext.CancellationToken); }); private static readonly IFieldResolver References = CreateAsyncValueResolver((value, fieldContext, context) => { - var cacheDuration = fieldContext.CacheDuration(); + var ids = value.AsIds(); - return context.GetReferencedContentsAsync(value, cacheDuration, fieldContext.CancellationToken); + return context.GetContentsAsync(ids, + fieldContext.FieldNames(), + fieldContext.CacheDuration(), + fieldContext.CancellationToken); }); private readonly Builder builder; @@ -265,7 +270,7 @@ public FieldGraphSchema Visit(IField field, FieldInfo args) { var union = builder.GetContentUnion(fieldInfo.UnionReferenceType, schemaIds); - if (!union.HasType) + if (union.SchemaTypes.Count == 0) { return null; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs index 7517a72d18..cc16ffca15 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using GraphQL; using GraphQL.Types; using GraphQLParser.AST; @@ -24,4 +25,18 @@ public CacheDirective() DefaultValue = 600 }); } + + public static TimeSpan CacheDuration(IResolveFieldContext context) + { + var cacheDirective = context.GetDirective("cache"); + + if (cacheDirective != null) + { + var duration = cacheDirective.GetArgument("duration"); + + return TimeSpan.FromSeconds(duration); + } + + return TimeSpan.Zero; + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/OptimizeFieldQueriesDirective.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/OptimizeFieldQueriesDirective.cs new file mode 100644 index 0000000000..c7b4ef1d43 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/OptimizeFieldQueriesDirective.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL; +using GraphQL.Types; +using GraphQLParser.AST; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives; + +public sealed class OptimizeFieldQueriesDirective : Directive +{ + public OptimizeFieldQueriesDirective() + : base("optimizeFieldQueries", DirectiveLocation.Field, DirectiveLocation.FragmentSpread, DirectiveLocation.InlineFragment) + { + Description = "Enable Query Optimizations"; + } + + public static bool IsApplied(IResolveFieldContext context) + { + return context.GetDirective("optimizeFieldQueries") != null; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntityResolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntityResolvers.cs index cc8449f826..ea42b0f001 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntityResolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntityResolvers.cs @@ -6,6 +6,7 @@ // ========================================================================== using GraphQL.Resolvers; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; using Squidex.Infrastructure; using Squidex.Shared.Users; @@ -29,6 +30,16 @@ private static IFieldResolver Resolve(Func resolver) private static IFieldResolver ResolveUser(Func resolver) { - return Resolvers.Async((source, fieldContext, context) => context.FindUserAsync(resolver(source), fieldContext.CancellationToken)); + return Resolvers.Async((source, fieldContext, context) => + { + var token = resolver(source); + + if (fieldContext.HasOnlyIdField()) + { + return new ValueTask(new ClientUser(token)); + } + + return context.FindUserAsync(token, fieldContext.CancellationToken); + }); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs index 7d837c0586..bda2990050 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs @@ -82,8 +82,8 @@ public static IFieldResolver Command(string permissionId, Func() - .PublishAsync(command, fieldContext.CancellationToken); + await context.Resolve().PublishAsync(command, + fieldContext.CancellationToken); return commandContext.PlainResult!; }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs index 2ea9237e0d..9978dc3f0b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs @@ -8,9 +8,14 @@ using GraphQL; using GraphQL.Types; using GraphQL.Utilities; +using GraphQLParser; +using GraphQLParser.AST; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.ObjectPool; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; @@ -100,12 +105,12 @@ internal static string SourceName(this FieldType field) internal static FieldType WithSchemaId(this FieldType field, SchemaInfo value) { - return field.WithMetadata(nameof(SchemaId), value.Schema.Id.ToString()); + return field.WithMetadata(nameof(SchemaId), value.Schema.Id); } - internal static string SchemaId(this FieldType field) + internal static DomainId SchemaId(this FieldType field) { - return field.GetMetadata(nameof(SchemaId))!; + return field.GetMetadata(nameof(SchemaId))!; } internal static FieldType WithSchemaNamedId(this FieldType field, SchemaInfo value) @@ -118,16 +123,11 @@ internal static NamedId SchemaNamedId(this FieldType field) return field.GetMetadata>(nameof(SchemaNamedId))!; } - internal static IGraphType? Flatten(this QueryArgument type) - { - return type.ResolvedType?.Flatten(); - } - - public static IGraphType? Flatten(this IGraphType type) + public static IGraphType? InnerType(this IGraphType type) { if (type is IProvideResolvedType provider) { - return provider.ResolvedType?.Flatten(); + return provider.ResolvedType?.InnerType(); } return type; @@ -135,15 +135,151 @@ internal static NamedId SchemaNamedId(this FieldType field) public static TimeSpan CacheDuration(this IResolveFieldContext context) { - var cacheDirective = context.GetDirective("cache"); + return CacheDirective.CacheDuration(context); + } + + public static List? AsIds(this JsonValue value) + { + try + { + List? result = null; + + if (value.Value is JsonArray a) + { + foreach (var item in a) + { + if (item.Value is string id) + { + result ??= new List(); + result.Add(DomainId.Create(id)); + } + } + } + + return result; + } + catch + { + return null; + } + } + + public static bool HasOnlyIdField(this IResolveFieldContext context) + { + return context.FieldAst.SelectionSet?.Selections.TrueForAll(x => x is GraphQLField field && field.Name == "id") == true; + } + + public static HashSet? FieldNames(this IResolveFieldContext context) + { + if (!OptimizeFieldQueriesDirective.IsApplied(context)) + { + return null; + } + + return new FieldNameResolver(context.Document, context.Schema).Iterate(context.FieldAst, context.FieldDefinition.ResolvedType); + } + + private sealed class FieldNameResolver + { + private readonly GraphQLDocument document; + private readonly ISchema schema; + private HashSet? fieldNames = new HashSet(); + private IComplexGraphType? currentParent; - if (cacheDirective != null) + public FieldNameResolver(GraphQLDocument document, ISchema schema) { - var duration = cacheDirective.GetArgument("duration"); + this.document = document; + this.schema = schema; + } + + public HashSet? Iterate(GraphQLField field, IGraphType? type) + { + currentParent = ResolveDataParent(type); - return TimeSpan.FromSeconds(duration); + IterateContent(field.SelectionSet); + return fieldNames; } - return TimeSpan.Zero; + private void IterateContent(GraphQLSelectionSet? selection) + { + if (selection == null) + { + return; + } + + foreach (var selectedField in selection.Selections) + { + switch (selectedField) + { + case GraphQLField field when field.Name == "data": + IterateData(field.SelectionSet); + break; + case GraphQLField field when field.Name == "flatData": + IterateData(field.SelectionSet); + break; + case GraphQLField field when field.Name == "data__dynamic": + fieldNames = null; + return; + case GraphQLFragmentSpread spread: + var fragment = document.FindFragmentDefinition(spread.FragmentName.Name); + + IterateContent(fragment?.SelectionSet); + break; + + case GraphQLInlineFragment inline when inline.TypeCondition != null: + currentParent = ResolveDataParent(schema.AllTypes.FirstOrDefault(x => x.Name == inline.TypeCondition.Type.Name())); + + IterateContent(inline.SelectionSet); + break; + } + } + } + + private void IterateData(GraphQLSelectionSet? selection) + { + if (selection == null) + { + return; + } + + foreach (var selectedField in selection.Selections) + { + switch (selectedField) + { + case GraphQLField field when currentParent != null: + // The GraphQL field might be different from the schema name. Therefore we need the field type. + var fieldType = currentParent.Fields.Find(field.Name.StringValue); + + if (fieldType != null) + { + // Resolve the schema name from the GraphQL field. + fieldNames?.Add(fieldType.SourceName()); + } + + break; + case GraphQLFragmentSpread spread: + var fragment = document.FindFragmentDefinition(spread.FragmentName.Name); + + IterateData(fragment?.SelectionSet); + break; + } + } + } + + private static IComplexGraphType? ResolveDataParent(IGraphType? type) + { + if (type?.InnerType() is not IComplexGraphType complexType) + { + return null; + } + + // We need to resolve the schema names from the GraphQL field name and the flatData type has this information. + if (complexType?.Fields.Find("flatData")?.ResolvedType?.InnerType() is not IComplexGraphType dataParent) + { + return null; + } + + return dataParent; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs index e9b55100ed..79051e1fe1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs @@ -24,7 +24,9 @@ public static class SharedTypes public static readonly IGraphType EnrichedContentEvent = new EnrichedContentEventGraphType(); - public static readonly CacheDirective MemoryCacheDirective = new CacheDirective(); + public static readonly CacheDirective CacheDirective = new CacheDirective(); + + public static readonly OptimizeFieldQueriesDirective OptimizeFieldQueriesDirective = new OptimizeFieldQueriesDirective(); public static readonly FieldType FindAsset = new FieldType { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs index 11c96f28bd..cf70a1af58 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -60,6 +60,7 @@ public class ContentQueryParser WithPaging(query, q); q = q.WithQuery(query); + q = q.WithFields(context.FieldsList()); if (context.ShouldSkipTotal()) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs index a192ccc7ca..230ccdb375 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs @@ -35,7 +35,7 @@ private GeoQueryTransformer() { if (nodeIn.Value.Value is FilterSphere sphere) { - var field = string.Join(".", nodeIn.Path.Skip(1)); + var field = string.Join('.', nodeIn.Path.Skip(1)); var searchQuery = new GeoQuery(args.Schema.Id, field, sphere.Latitude, sphere.Longitude, sphere.Radius, 1000); var searchScope = args.Context.Scope(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 2ac080eca9..07bd7b1c9b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -95,41 +95,35 @@ public abstract class QueryExecutionContext : Dictionary { Guard.NotNull(ids); - return await AssetCache.CacheOrQueryAsync(ids, async pendingIds => + await maxRequests.WaitAsync(ct); + try + { + var q = Q.Empty.WithIds(ids).WithoutTotal(); + + return await AssetQuery.QueryAsync(Context, null, q, ct); + } + finally { - await maxRequests.WaitAsync(ct); - try - { - var q = Q.Empty.WithIds(pendingIds).WithoutTotal(); - - return await AssetQuery.QueryAsync(Context, null, q, ct); - } - finally - { - maxRequests.Release(); - } - }); + maxRequests.Release(); + } } - public virtual async Task> QueryContentsByIdsAsync(IEnumerable ids, + public virtual async Task> QueryContentsByIdsAsync(IEnumerable ids, HashSet? fields, CancellationToken ct) { Guard.NotNull(ids); - return await ContentCache.CacheOrQueryAsync(ids, async pendingIds => + await maxRequests.WaitAsync(ct); + try + { + var q = Q.Empty.WithIds(ids).WithFields(fields).WithoutTotal(); + + return await ContentQuery.QueryAsync(Context, q, ct); + } + finally { - await maxRequests.WaitAsync(ct); - try - { - var q = Q.Empty.WithIds(pendingIds).WithoutTotal(); - - return await ContentQuery.QueryAsync(Context, q, ct); - } - finally - { - maxRequests.Release(); - } - }); + maxRequests.Release(); + } } public T Resolve() where T : class diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs index e01ba8f2f8..08fe6cc768 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs @@ -136,7 +136,7 @@ private ContentConverter GenerateConverter(Context context, ResolvedComponents c converter.Add( new ResolveLanguages( context.App.Languages, - context.LanguageList().ToArray()) + context.LanguagesList().ToArray()) { ResolveFallback = !context.IsFrontendClient && context.ShouldResolveLanguages() }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs b/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs index 36e6fb9f50..d08cf124ab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs @@ -9,10 +9,10 @@ namespace Squidex.Domain.Apps.Entities; public static class ContextHeaders { - public const string NoTotal = "X-NoTotal"; - public const string NoSlowTotal = "X-NoSlowTotal"; public const string NoCacheKeys = "X-NoCacheKeys"; public const string NoScripting = "X-NoScripting"; + public const string NoSlowTotal = "X-NoSlowTotal"; + public const string NoTotal = "X-NoTotal"; public static bool ShouldSkipCacheKeys(this Context context) { @@ -72,7 +72,7 @@ public static ICloneBuilder WithStrings(this ICloneBuilder builder, string key, { if (values?.Any() == true) { - builder.SetHeader(key, string.Join(",", values)); + builder.SetHeader(key, string.Join(',', values)); } else { diff --git a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index 52d07c161d..591434707d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -51,4 +51,6 @@ public interface IAppProvider Task GetRuleAsync(DomainId appId, DomainId id, CancellationToken ct = default); + + void RegisterAppForLocalContext(DomainId appId, IAppEntity app); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Q.cs b/backend/src/Squidex.Domain.Apps.Entities/Q.cs index d7f3007ddf..965c8c17cd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Q.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Q.cs @@ -20,6 +20,8 @@ public record Q public IReadOnlyList? Ids { get; init; } + public IReadOnlySet? Fields { get; init; } + public DomainId Referencing { get; init; } public DomainId Reference { get; init; } @@ -91,16 +93,31 @@ public Q WithIds(params DomainId[] ids) return this with { Ids = ids?.ToList() }; } - public Q WithIds(IEnumerable ids) + public Q WithIds(IEnumerable? ids) { return this with { Ids = ids?.ToList() }; } + public Q WithFields(params string[] fields) + { + return this with { Fields = fields?.ToHashSet() }; + } + + public Q WithFields(IEnumerable? fields) + { + return this with { Fields = fields?.ToHashSet() }; + } + public Q WithSchedule(Instant from, Instant to) { return this with { ScheduledFrom = from, ScheduledTo = to }; } + public Q WithFields(string? fields) + { + return this with { Fields = fields?.Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet() }; + } + public Q WithIds(string? ids) { if (string.IsNullOrWhiteSpace(ids)) @@ -110,9 +127,9 @@ public Q WithIds(string? ids) var idsList = new List(); - if (!string.IsNullOrEmpty(ids)) + if (ids.Length > 0) { - foreach (var id in ids.Split(',')) + foreach (var id in ids.Split(',', StringSplitOptions.RemoveEmptyEntries)) { idsList.Add(DomainId.Create(id)); } diff --git a/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs b/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs index 2311bf2e1f..12720199d7 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs +++ b/backend/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs @@ -628,7 +628,7 @@ public IdentityUser Create(string email) public Task ReplaceCodesAsync(IdentityUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) { - ((MongoUser)user).ReplaceToken(InternalLoginProvider, RecoveryCodeTokenName, string.Join(";", recoveryCodes)); + ((MongoUser)user).ReplaceToken(InternalLoginProvider, RecoveryCodeTokenName, string.Join(';', recoveryCodes)); return Task.CompletedTask; } @@ -643,7 +643,7 @@ public IdentityUser Create(string email) { var updatedCodes = new List(splitCodes.Where(s => s != code)); - ((MongoUser)user).ReplaceToken(InternalLoginProvider, RecoveryCodeTokenName, string.Join(";", updatedCodes)); + ((MongoUser)user).ReplaceToken(InternalLoginProvider, RecoveryCodeTokenName, string.Join(';', updatedCodes)); return Task.FromResult(true); } diff --git a/backend/src/Squidex.Domain.Users/UserWithClaims.cs b/backend/src/Squidex.Domain.Users/UserWithClaims.cs index a1b0372e8e..cfbe9867bf 100644 --- a/backend/src/Squidex.Domain.Users/UserWithClaims.cs +++ b/backend/src/Squidex.Domain.Users/UserWithClaims.cs @@ -30,7 +30,7 @@ public string Email public bool IsLocked { - get => snapshot.LockoutEnd > DateTime.UtcNow; + get => snapshot.LockoutEnd > DateTimeOffset.UtcNow; } public IReadOnlyList Claims { get; } diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs index a2c09d7af0..f9a4901144 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs @@ -14,7 +14,6 @@ namespace Squidex.Infrastructure.EventSourcing; public partial class MongoEventStore { - private const int MaxCommitSize = 100; private const int MaxWriteAttempts = 20; private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0); diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs index b13d80ebfe..ede65dc222 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs @@ -71,35 +71,35 @@ public static class MongoExtensions } public static IFindFluent Only(this IFindFluent find, - Expression> include) + Expression> include) { return find.Project(Builders.Projection.Include(include)); } public static IFindFluent Only(this IFindFluent find, - Expression> include1, - Expression> include2) + Expression> include1, + Expression> include2) { return find.Project(Builders.Projection.Include(include1).Include(include2)); } public static IFindFluent Only(this IFindFluent find, - Expression> include1, - Expression> include2, - Expression> include3) + Expression> include1, + Expression> include2, + Expression> include3) { return find.Project(Builders.Projection.Include(include1).Include(include2).Include(include3)); } public static IFindFluent Not(this IFindFluent find, - Expression> exclude) + Expression> exclude) { return find.Project(Builders.Projection.Exclude(exclude)); } public static IFindFluent Not(this IFindFluent find, - Expression> exclude1, - Expression> exclude2) + Expression> exclude1, + Expression> exclude2) { return find.Project(Builders.Projection.Exclude(exclude1).Exclude(exclude2)); } diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/ProfilerCollection.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/ProfilerCollection.cs index 43730669fe..87320c5077 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/ProfilerCollection.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/ProfilerCollection.cs @@ -16,11 +16,9 @@ public sealed class ProfilerCollection { private readonly IMongoCollection collection; - public string CollectionName => "system.profile"; - public ProfilerCollection(IMongoDatabase database) { - collection = database.GetCollection(CollectionName); + collection = database.GetCollection("system.profile"); } public async Task> GetQueriesAsync(string collectionName, diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs index 21e4e3ae93..dd230bdef5 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs @@ -33,7 +33,7 @@ public static class SortBuilder public static SortDefinition OrderBy(SortNode sort) { - var propertyName = string.Join(".", sort.Path); + var propertyName = string.Join('.', sort.Path); if (sort.Order == SortOrder.Ascending) { diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs index 767a3e6cca..1afb813364 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObject.cs @@ -344,7 +344,7 @@ protected virtual bool CanRecreate(IEvent @event) } } - public async Task RebuildStateAsync( + public async virtual Task RebuildStateAsync( CancellationToken ct = default) { await EnsureLoadedAsync(ct); diff --git a/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs index 1d1ad422ad..bf9a40c6d5 100644 --- a/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs +++ b/backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Threading.Tasks.Dataflow; +using Google.Api; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Squidex.Caching; @@ -37,6 +38,19 @@ public class Rebuilder this.log = log; } + public virtual async Task RebuildStateAsync(DomainId id, + CancellationToken ct = default) + where T : DomainObject where TState : class, IDomainState, new() + { + var store = serviceProvider.GetRequiredService>(); + + var domainObject = domainObjectFactory.Create(id, store); + + await domainObject.EnsureLoadedAsync(ct); + + return domainObject; + } + public virtual Task RebuildAsync(string filter, int batchSize, CancellationToken ct = default) where T : DomainObject where TState : class, IDomainState, new() diff --git a/backend/src/Squidex.Infrastructure/Json/System/Constants.cs b/backend/src/Squidex.Infrastructure/Json/System/Constants.cs index 2a12629487..9f33c4cc01 100644 --- a/backend/src/Squidex.Infrastructure/Json/System/Constants.cs +++ b/backend/src/Squidex.Infrastructure/Json/System/Constants.cs @@ -9,7 +9,7 @@ namespace Squidex.Infrastructure.Json.System; -internal sealed class Constants +internal static class Constants { public const string DefaultDiscriminatorProperty = "$type"; diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelConverter.cs b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelConverter.cs index 01f76a9b41..6d71017650 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelConverter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelConverter.cs @@ -63,7 +63,7 @@ void Convert(EdmStructuredType target, FilterSchema schema) entityPath.Push(fieldName); - var typeName = string.Join("_", entityPath.Reverse().Select(x => x.EscapeEdmField().ToPascalCase())); + var typeName = string.Join('_', entityPath.Reverse().Select(x => x.EscapeEdmField().ToPascalCase())); var result = model.SchemaElements.OfType().FirstOrDefault(x => x.Name == typeName); @@ -114,11 +114,11 @@ void Convert(EdmStructuredType target, FilterSchema schema) public static string EscapeEdmField(this string field) { - return field.Replace("-", "_", StringComparison.Ordinal); + return field.Replace('-', '_'); } public static string UnescapeEdmField(this string field) { - return field.Replace("_", "-", StringComparison.Ordinal); + return field.Replace('_', '-'); } } diff --git a/backend/src/Squidex.Infrastructure/Queries/PropertyPath.cs b/backend/src/Squidex.Infrastructure/Queries/PropertyPath.cs index 3ee072d8ad..ba990e6039 100644 --- a/backend/src/Squidex.Infrastructure/Queries/PropertyPath.cs +++ b/backend/src/Squidex.Infrastructure/Queries/PropertyPath.cs @@ -90,7 +90,7 @@ public bool Equals(string? other) public override string ToString() { - return string.Join(".", this); + return string.Join('.', this); } private static PropertyPath Create(IEnumerable? source) diff --git a/backend/src/Squidex.Infrastructure/Queries/Query.cs b/backend/src/Squidex.Infrastructure/Queries/Query.cs index 353a1def2b..680f602b8a 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Query.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Query.cs @@ -58,7 +58,7 @@ public override string ToString() if (FullText != null) { sb.AppendIfNotEmpty("; "); - sb.Append($"FullText: '{FullText.Replace("'", "\'", StringComparison.Ordinal)}'"); + sb.Append($"FullText: '{FullText.Replace('\'', '\'')}'"); } if (Skip > 0) diff --git a/backend/src/Squidex.Infrastructure/Queries/SortNode.cs b/backend/src/Squidex.Infrastructure/Queries/SortNode.cs index f131cd8f07..7312698d06 100644 --- a/backend/src/Squidex.Infrastructure/Queries/SortNode.cs +++ b/backend/src/Squidex.Infrastructure/Queries/SortNode.cs @@ -13,7 +13,7 @@ public sealed record SortNode(PropertyPath Path, SortOrder Order) { public override string ToString() { - var path = string.Join(".", Path); + var path = string.Join('.', Path); return $"{path} {Order}"; } diff --git a/backend/src/Squidex.Infrastructure/RandomHash.cs b/backend/src/Squidex.Infrastructure/RandomHash.cs index 983dff5c72..8a6af90189 100644 --- a/backend/src/Squidex.Infrastructure/RandomHash.cs +++ b/backend/src/Squidex.Infrastructure/RandomHash.cs @@ -17,9 +17,9 @@ public static string New() return Guid.NewGuid() .ToString().ToSha256Base64() .ToLowerInvariant() - .Replace("+", "x", StringComparison.Ordinal) - .Replace("=", "x", StringComparison.Ordinal) - .Replace("/", "x", StringComparison.Ordinal); + .Replace('+', 'x') + .Replace('=', 'x') + .Replace('/', 'x'); } public static string Simple() diff --git a/backend/src/Squidex.Infrastructure/Security/PermissionSet.cs b/backend/src/Squidex.Infrastructure/Security/PermissionSet.cs index 4aa32c3524..f83c086ff0 100644 --- a/backend/src/Squidex.Infrastructure/Security/PermissionSet.cs +++ b/backend/src/Squidex.Infrastructure/Security/PermissionSet.cs @@ -38,7 +38,7 @@ public PermissionSet(IEnumerable permissions) public PermissionSet(IList permissions) : base(permissions) { - display = new Lazy(() => string.Join(";", this)); + display = new Lazy(() => string.Join(';', this)); } public PermissionSet Add(string permission) diff --git a/backend/src/Squidex.Web/ContextProvider.cs b/backend/src/Squidex.Web/ContextProvider.cs index 79fe4c9382..3e85d68151 100644 --- a/backend/src/Squidex.Web/ContextProvider.cs +++ b/backend/src/Squidex.Web/ContextProvider.cs @@ -21,10 +21,7 @@ public Context Context { if (httpContextAccessor.HttpContext == null) { - if (asyncLocal.Value == null) - { - asyncLocal.Value = Context.Anonymous(null!); - } + asyncLocal.Value ??= Context.Anonymous(null!); return asyncLocal.Value; } diff --git a/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs index 0f56cb7994..8e52693318 100644 --- a/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs +++ b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs @@ -57,9 +57,7 @@ await foreach (var item in stream.WithCancellation(context.HttpContext.RequestAb private static void DisableResponseBuffering(HttpContext context) { var bufferingFeature = context.Features.Get(); - if (bufferingFeature != null) - { - bufferingFeature.DisableBuffering(); - } + + bufferingFeature?.DisableBuffering(); } } diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeader.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeader.cs new file mode 100644 index 0000000000..f36f3dc936 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeader.cs @@ -0,0 +1,101 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema; +using NSwag; +using NSwag.Annotations; +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Contents; + +#pragma warning disable MA0048 // File name must match type name + +namespace Squidex.Areas.Api.Config.OpenApi; + +public class AcceptHeader +{ + public sealed class UnpublishedAttribute : BaseAttribute + { + public UnpublishedAttribute() + : base(ContentHeaders.Unpublished, "Return unpublished content items.", JsonObjectType.Boolean) + { + } + } + + public sealed class FieldsAttribute : BaseAttribute + { + public FieldsAttribute() + : base(ContentHeaders.Fields, "The list of content fields (comma-separated).", JsonObjectType.String) + { + } + } + + public sealed class FlattenAttribute : BaseAttribute + { + public FlattenAttribute() + : base(ContentHeaders.Flatten, "Provide the data as flat object.", JsonObjectType.Boolean) + { + } + } + + public sealed class LanguagesAttribute : BaseAttribute + { + public LanguagesAttribute() + : base(ContentHeaders.Languages, "The list of languages to resolve (comma-separated).") + { + } + } + + public sealed class NoTotalAttribute : BaseAttribute + { + public NoTotalAttribute() + : base(ContextHeaders.NoTotal, "Do not return the total amount.", JsonObjectType.Boolean) + { + } + } + + public sealed class NoSlowTotalAttribute : BaseAttribute + { + public NoSlowTotalAttribute() + : base(ContextHeaders.NoSlowTotal, "Do not return the total amount, if it would be slow.", JsonObjectType.Boolean) + { + } + } + + public abstract class BaseAttribute : OpenApiOperationProcessorAttribute + { + protected BaseAttribute(string name, string description, JsonObjectType schemaType = JsonObjectType.String) + : base(typeof(Processor), name, description, schemaType) + { + } + +#pragma warning disable IDE1006 // Naming Styles + public record Processor(string name, string description, JsonObjectType schemaType) : IOperationProcessor +#pragma warning restore IDE1006 // Naming Styles + { + public bool Process(OperationProcessorContext context) + { + var parameter = new OpenApiParameter + { + Name = name, + Kind = OpenApiParameterKind.Header, + Schema = new JsonSchema + { + Type = schemaType + }, + Description = description + }; + + context.OperationDescription.Operation.Parameters.Add(parameter); + context.OperationDescription.Operation.SetPositions(); + + return true; + } + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeaderAttribute.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeaderAttribute.cs deleted file mode 100644 index 315bbea833..0000000000 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/AcceptHeaderAttribute.cs +++ /dev/null @@ -1,99 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NJsonSchema; -using NSwag; -using NSwag.Annotations; -using NSwag.Generation.Processors; -using NSwag.Generation.Processors.Contexts; -using Squidex.Domain.Apps.Entities; -using Squidex.Domain.Apps.Entities.Contents; - -#pragma warning disable MA0048 // File name must match type name - -namespace Squidex.Areas.Api.Config.OpenApi; - -public sealed class AcceptHeader_Unpublished : AcceptHeaderAttribute -{ - public AcceptHeader_Unpublished() - : base(ContentHeaders.Unpublished, "Return unpublished content items.", JsonObjectType.Boolean) - { - } -} - -public sealed class AcceptHeader_Flatten : AcceptHeaderAttribute -{ - public AcceptHeader_Flatten() - : base(ContentHeaders.Flatten, "Provide the data as flat object.", JsonObjectType.Boolean) - { - } -} - -public sealed class AcceptHeader_Languages : AcceptHeaderAttribute -{ - public AcceptHeader_Languages() - : base(ContentHeaders.Languages, "Only resolve these languages (comma-separated).") - { - } -} - -public sealed class AcceptHeader_NoTotal : AcceptHeaderAttribute -{ - public AcceptHeader_NoTotal() - : base(ContextHeaders.NoTotal, "Do not return the total amount.", JsonObjectType.Boolean) - { - } -} - -public sealed class AcceptHeader_NoSlowTotal : AcceptHeaderAttribute -{ - public AcceptHeader_NoSlowTotal() - : base(ContextHeaders.NoSlowTotal, "Do not return the total amount, if it would be slow.", JsonObjectType.Boolean) - { - } -} - -public class AcceptHeaderAttribute : OpenApiOperationProcessorAttribute -{ - public AcceptHeaderAttribute(string name, string description, JsonObjectType schemaType = JsonObjectType.String) - : base(typeof(Processor), name, description, schemaType) - { - } - - public sealed class Processor : IOperationProcessor - { - private readonly string name; - private readonly string description; - private readonly JsonObjectType schemaType; - - public Processor(string name, string description, JsonObjectType schemaType) - { - this.name = name; - this.description = description; - this.schemaType = schemaType; - } - - public bool Process(OperationProcessorContext context) - { - var parameter = new OpenApiParameter - { - Name = name, - Kind = OpenApiParameterKind.Header, - Schema = new JsonSchema - { - Type = schemaType - }, - Description = description - }; - - context.OperationDescription.Operation.Parameters.Add(parameter); - context.OperationDescription.Operation.SetPositions(); - - return true; - } - } -} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 824c2d352f..a5879b0db3 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -115,8 +115,8 @@ public async Task PutTag(string app, string name, [FromBody] Rena [ApiPermissionOrAnonymous(PermissionIds.AppAssetsRead)] [ApiCosts(1)] [AcceptQuery(false)] - [AcceptHeader_NoTotal] - [AcceptHeader_NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.NoSlowTotal] public async Task GetAssets(string app, [FromQuery] DomainId? parentId, [FromQuery] string? ids = null, [FromQuery] string? q = null) { var assets = await assetQuery.QueryAsync(Context, parentId, CreateQuery(ids, q), HttpContext.RequestAborted); @@ -144,8 +144,8 @@ public async Task GetAssets(string app, [FromQuery] DomainId? par [ProducesResponseType(typeof(AssetsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppAssetsRead)] [ApiCosts(1)] - [AcceptHeader_NoTotal] - [AcceptHeader_NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.NoSlowTotal] public async Task GetAssetsPost(string app, [FromBody] QueryDto query) { var assets = await assetQuery.QueryAsync(Context, query?.ParentId, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 961e987201..f508d3052c 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -79,11 +79,12 @@ public IActionResult StreamContents(string app, string schema, [FromQuery] int s [ApiPermissionOrAnonymous] [ApiCosts(1)] [AcceptQuery(true)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] - [AcceptHeader_Unpublished] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.Unpublished] public async Task GetContents(string app, string schema, [FromQuery] string? ids = null, [FromQuery] string? q = null) { var contents = await contentQuery.QueryAsync(Context, schema, CreateQuery(ids, q), HttpContext.RequestAborted); @@ -112,11 +113,12 @@ public async Task GetContents(string app, string schema, [FromQue [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] - [AcceptHeader_Unpublished] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.Unpublished] public async Task GetContentsPost(string app, string schema, [FromBody] QueryDto query) { var contents = await contentQuery.QueryAsync(Context, schema, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); @@ -146,9 +148,10 @@ public async Task GetContentsPost(string app, string schema, [Fro [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_Unpublished] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.Unpublished] public async Task GetContent(string app, string schema, DomainId id, long version = EtagVersion.Any) { var content = await contentQuery.FindAsync(Context, schema, id, version, HttpContext.RequestAborted); @@ -209,11 +212,12 @@ public async Task GetContentValidity(string app, string schema, D [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_Unpublished] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] public async Task GetReferences(string app, string schema, DomainId id, [FromQuery] string? q = null) { var contents = await contentQuery.QueryAsync(Context, CreateQuery(null, q).WithReferencing(id), HttpContext.RequestAborted); @@ -243,11 +247,12 @@ public async Task GetReferences(string app, string schema, Domain [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_Unpublished] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] public async Task GetReferencing(string app, string schema, DomainId id, [FromQuery] string? q = null) { var contents = await contentQuery.QueryAsync(Context, CreateQuery(null, q).WithReference(id), HttpContext.RequestAborted); @@ -276,8 +281,8 @@ public async Task GetReferencing(string app, string schema, Domai [Route("content/{app}/{schema}/{id}/{version}/")] [ApiPermissionOrAnonymous(PermissionIds.AppContentsReadOwn)] [ApiCosts(1)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [Obsolete("Use ID endpoint with version query.")] public async Task GetContentVersion(string app, string schema, DomainId id, int version) { @@ -312,8 +317,8 @@ public async Task GetContentVersion(string app, string schema, Do [Route("content/{app}/{schema}/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsCreate)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task PostContent(string app, string schema, CreateContentDto request) { @@ -400,8 +405,8 @@ public async Task BulkUpdateContents(string app, string schema, [ [Route("content/{app}/{schema}/{id}/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsUpsert)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task PostUpsertContent(string app, string schema, DomainId id, UpsertContentDto request) { @@ -429,8 +434,8 @@ public async Task PostUpsertContent(string app, string schema, Do [Route("content/{app}/{schema}/{id}/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsUpdateOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task PutContent(string app, string schema, DomainId id, [FromBody] ContentData request) { @@ -458,8 +463,8 @@ public async Task PutContent(string app, string schema, DomainId [Route("content/{app}/{schema}/{id}/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsUpdateOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task PatchContent(string app, string schema, DomainId id, [FromBody] ContentData request) { @@ -487,8 +492,8 @@ public async Task PatchContent(string app, string schema, DomainI [Route("content/{app}/{schema}/{id}/status/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsChangeStatusOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task PutContentStatus(string app, string schema, DomainId id, [FromBody] ChangeStatusDto request) { @@ -515,8 +520,8 @@ public async Task PutContentStatus(string app, string schema, Dom [Route("content/{app}/{schema}/{id}/status/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsChangeStatusOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task DeleteContentStatus(string app, string schema, DomainId id) { @@ -542,8 +547,8 @@ public async Task DeleteContentStatus(string app, string schema, [Route("content/{app}/{schema}/{id}/draft/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsVersionCreateOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task CreateDraft(string app, string schema, DomainId id) { @@ -569,8 +574,8 @@ public async Task CreateDraft(string app, string schema, DomainId [Route("content/{app}/{schema}/{id}/draft/")] [ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppContentsVersionDeleteOwn)] - [AcceptHeader_Unpublished] - [AcceptHeader_Languages] + [AcceptHeader.Unpublished] + [AcceptHeader.Languages] [ApiCosts(1)] public async Task DeleteVersion(string app, string schema, DomainId id) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs index e2813b0d89..93a2c54cc2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs @@ -48,7 +48,7 @@ public sealed class ContentsSharedController : ApiController [Route("content/{app}/graphql/batch")] [ApiPermissionOrAnonymous] [ApiCosts(2)] - [AcceptHeader_Unpublished] + [AcceptHeader.Unpublished] [IgnoreCacheFilter] public IActionResult GetGraphQL(string app) { @@ -75,11 +75,12 @@ public IActionResult GetGraphQL(string app) [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] - [AcceptHeader_Unpublished] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.Unpublished] public async Task GetAllContents(string app, AllContentsByGetDto query) { var contents = await contentQuery.QueryAsync(Context, (query ?? new AllContentsByGetDto()).ToQuery(Request), HttpContext.RequestAborted); @@ -107,11 +108,12 @@ public async Task GetAllContents(string app, AllContentsByGetDto [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous] [ApiCosts(1)] - [AcceptHeader_Flatten] - [AcceptHeader_Languages] - [AcceptHeader_NoSlowTotal] - [AcceptHeader_NoTotal] - [AcceptHeader_Unpublished] + [AcceptHeader.Fields] + [AcceptHeader.Flatten] + [AcceptHeader.Languages] + [AcceptHeader.NoSlowTotal] + [AcceptHeader.NoTotal] + [AcceptHeader.Unpublished] public async Task GetAllContentsPost(string app, [FromBody] AllContentsByPostDto query) { var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/AlwaysAddScopeHandler.cs b/backend/src/Squidex/Areas/IdentityServer/Config/AlwaysAddScopeHandler.cs index 8a28e47e11..ab81ac535c 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/AlwaysAddScopeHandler.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/AlwaysAddScopeHandler.cs @@ -25,7 +25,7 @@ public ValueTask HandleAsync(ProcessSignInContext context) { var scopes = context.AccessTokenPrincipal?.GetScopes() ?? ImmutableArray.Empty; - context.Response.Scope = string.Join(" ", scopes); + context.Response.Scope = string.Join(' ', scopes); } return default; diff --git a/backend/src/Squidex/Config/Domain/BackupsServices.cs b/backend/src/Squidex/Config/Domain/BackupsServices.cs index 402684d163..6ac514888c 100644 --- a/backend/src/Squidex/Config/Domain/BackupsServices.cs +++ b/backend/src/Squidex/Config/Domain/BackupsServices.cs @@ -51,9 +51,6 @@ public static void AddSquidexBackups(this IServiceCollection services) services.AddTransientAs() .As(); - services.AddTransientAs() - .As(); - services.AddTransientAs() .AsSelf(); } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs index 4a129005c7..d71914ddf1 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs @@ -293,6 +293,6 @@ public async Task Should_return_json_string_if_array() Assert.Equal("{'categories':['ref1','ref2','ref3']}", actual? .Replace(" ", string.Empty, StringComparison.Ordinal) - .Replace("\"", "'", StringComparison.Ordinal)); + .Replace('"', '\'')); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs index 55bdc9d1e8..68f53d8126 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs @@ -6,7 +6,6 @@ // ========================================================================== using Squidex.Assets; -using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Apps.DomainObject; using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Backup; @@ -25,11 +24,12 @@ public class BackupAppsTests : GivenContext private readonly IAppsIndex appsIndex = A.Fake(); private readonly IAppUISettings appUISettings = A.Fake(); private readonly IAppImageStore appImageStore = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); private readonly BackupApps sut; public BackupAppsTests() { - sut = new BackupApps(rebuilder, appImageStore, appsIndex, appUISettings); + sut = new BackupApps(rebuilder, appImageStore, appProvider, appsIndex, appUISettings); } [Fact] @@ -58,8 +58,17 @@ public async Task Should_reserve_app_name() [Fact] public async Task Should_complete_reservation_with_previous_token() { + var appObject = A.Fake(); + var appState = new AppDomainObject.State(); + var context = CreateRestoreContext(); + A.CallTo(() => appObject.Snapshot) + .Returns(appState); + + A.CallTo(() => rebuilder.RebuildStateAsync(context.AppId, CancellationToken)) + .Returns(appObject); + A.CallTo(() => appsIndex.ReserveAsync(AppId.Id, AppId.Name, CancellationToken)) .Returns("Reservation"); @@ -68,12 +77,13 @@ public async Task Should_complete_reservation_with_previous_token() Name = AppId.Name }), context, CancellationToken); + await sut.RestoreAsync(context, CancellationToken); await sut.CompleteRestoreAsync(context, AppId.Name); A.CallTo(() => appsIndex.RemoveReservationAsync("Reservation", default)) .MustHaveHappened(); - A.CallTo(() => rebuilder.InsertManyAsync(A>.That.Is(AppId.Id), 1, default)) + A.CallTo(() => appObject.RebuildStateAsync(default)) .MustHaveHappened(); } @@ -137,6 +147,26 @@ public async Task Should_write_user_settings() .MustHaveHappened(); } + [Fact] + public async Task Should_register_app_to_provider() + { + var appObject = A.Fake(); + var appState = new AppDomainObject.State(); + + var context = CreateRestoreContext(); + + A.CallTo(() => appObject.Snapshot) + .Returns(appState); + + A.CallTo(() => rebuilder.RebuildStateAsync(context.AppId, CancellationToken)) + .Returns(appObject); + + await sut.RestoreAsync(context, CancellationToken); + + A.CallTo(() => appProvider.RegisterAppForLocalContext(context.AppId, appObject.Snapshot)) + .MustHaveHappened(); + } + [Fact] public async Task Should_read_user_settings() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs index 01165fcb12..856ad32b51 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs @@ -120,7 +120,7 @@ public async Task Create_should_not_return_duplicate_actual_if_file_with_same_ha { var actual = CreateAsset(); - SetupSameHashAsset(file.FileName, file.FileSize, out var unused); + SetupSameHashAsset(file.FileName, file.FileSize, out var _); var context = await HandleAsync(new CreateAsset { File = file, Duplicate = true }, @@ -174,7 +174,7 @@ public async Task Upsert_should_not_return_duplicate_actual_if_file_with_same_ha { var actual = CreateAsset(); - SetupSameHashAsset(file.FileName, file.FileSize, out var unused); + SetupSameHashAsset(file.FileName, file.FileSize, out var _); var context = await HandleAsync(new UpsertAsset { File = file }, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs index 0c8cdd6bed..66ada93d2c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs @@ -34,13 +34,14 @@ public async Task Should_add_cache_headers() Assert.Equal(new List { + "X-Fields", "X-Flatten", "X-Languages", "X-NoCleanup", "X-NoEnrichment", "X-NoResolveLanguages", "X-ResolveFlow", - "X-Resolve-Urls", + "X-ResolveUrls", "X-Unpublished" }, headers); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs index 4407ebfe2f..5eddc052be 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs @@ -301,6 +301,6 @@ public async Task Should_not_build_grapqhl_schema_on_invalid_references() { var type = (IObjectGraphType)graphQLSchema.AllTypes.Single(x => x.Name == schema); - return (IObjectGraphType?)type.GetField("flatData")?.ResolvedType?.Flatten(); + return (IObjectGraphType?)type.GetField("flatData")?.ResolvedType?.InnerType(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index fae4846179..1308ea81ad 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -246,7 +246,8 @@ public async Task Should_return_null_if_single_asset_not_found() var query = CreateQuery(@" query { findAsset(id: '') { - id + id, + version } }", assetId); @@ -416,7 +417,8 @@ public async Task Should_return_null_if_single_content_not_found() var query = CreateQuery(@" query { findMySchemaContent(id: '') { - id + id, + version } }", contentId); @@ -447,7 +449,8 @@ public async Task Should_return_null_if_single_content_from_another_schema() var query = CreateQuery(@" query { findMySchemaContent(id: '') { - id + id, + version } }", contentId); @@ -1291,4 +1294,152 @@ public async Task Should_not_return_data_if_field_not_part_of_content() Assert.Contains("\"errors\"", json, StringComparison.Ordinal); } + + [Fact] + public async Task Should_query_only_selected_fields() + { + var query = CreateQuery(@" + query { + queryMySchemaContents @optimizeFieldQueries { + data { + myNumber { + iv + } + } + } + }"); + + await ExecuteAsync(new ExecutionOptions { Query = query }); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), TestSchemas.Default.Id.ToString(), + A.That.HasFields(new[] { "my-number" }), + A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_query_only_selected_flat_fields() + { + var query = CreateQuery(@" + query { + queryMySchemaContents @optimizeFieldQueries { + flatData { + myNumber + } + } + }"); + + await ExecuteAsync(new ExecutionOptions { Query = query }); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), TestSchemas.Default.Id.ToString(), + A.That.HasFields(new[] { "my-number" }), + A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_query_all_fields_when_directive_not_applied() + { + var query = CreateQuery(@" + query { + queryMySchemaContents { + data { + myNumber { + iv + } + } + } + }"); + + await ExecuteAsync(new ExecutionOptions { Query = query }); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), TestSchemas.Default.Id.ToString(), + A.That.Matches(x => x.Fields == null), + A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_query_all_fields_when_dynamic_data_is_queried() + { + var query = CreateQuery(@" + query { + queryMySchemaContents @optimizeFieldQueries { + flatData { + myNumber + } + data__dynamic + } + }"); + + await ExecuteAsync(new ExecutionOptions { Query = query }); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), TestSchemas.Default.Id.ToString(), + A.That.Matches(x => x.Fields == null), + A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_query_all_fields_across_schemas() + { + var query = CreateQuery(@" + query { + queryContentsByIds(ids: [""42""]) @optimizeFieldQueries { + ...on MySchema { + flatData { + myNumber + } + } + } + }"); + + await ExecuteAsync(new ExecutionOptions { Query = query }); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasFields(new[] { "my-number" }), + A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_fetch_user_if_only_is_id_queried() + { + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId); + + var query = CreateQuery(@" + query { + findMySchemaContent(id: '') { + createdByUser { + id + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .Returns(ResultList.CreateFrom(1, content)); + + var actual = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + createdByUser = new + { + id = content.CreatedBy.Identifier + } + } + } + }; + + AssertResult(expected, actual); + + A.CallTo(() => userResolver.FindByIdAsync(A._, A._)) + .MustNotHaveHappened(); + } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 3f89f07d5c..48d34bd2ca 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -167,8 +167,8 @@ protected CachingGraphQLResolver CreateSut(params ISchemaEntity[] schemas) protected static string CreateQuery(string query, DomainId id = default, IEnrichedContentEntity? content = null) { query = query - .Replace("'", "\"", StringComparison.Ordinal) - .Replace("`", "\"", StringComparison.Ordinal) + .Replace('\'', '"') + .Replace('`', '"') .Replace("", TestAsset.AllFields, StringComparison.Ordinal) .Replace("", TestContent.AllFields, StringComparison.Ordinal) .Replace("", TestContent.AllFlatFields, StringComparison.Ordinal); @@ -202,7 +202,7 @@ protected Context MatchsAssetContext() return A.That.Matches(x => x.App == TestApp.Default && x.ShouldSkipCleanup() && - x.ShouldSkipContentEnrichment() && + x.ShouldSkipAssetEnrichment() && x.UserPrincipal == requestContext.UserPrincipal); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs index 0934978dba..e48ce9c3b4 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs @@ -9,22 +9,21 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.DomainObject; using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents.MongoDb; -public class ContentMappingTests +public class ContentMappingTests : GivenContext { - private readonly IAppProvider appProvider = A.Fake(); - [Fact] public async Task Should_map_content_without_new_version_to_draft() { var source = CreateContentWithoutNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, AppProvider, default); Assert.Equal(source.CurrentVersion.Data, snapshot.Data); Assert.Null(snapshot.DraftData); @@ -43,7 +42,7 @@ public async Task Should_map_content_without_new_version_to_published() var source = CreateContentWithoutNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, AppProvider, default); Assert.Equal(source.CurrentVersion.Data, snapshot.Data); Assert.Null(snapshot.DraftData); @@ -58,7 +57,7 @@ public async Task Should_map_content_with_new_version_to_draft() var source = CreateContentWithNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateCompleteAsync(snapshotJob, AppProvider, default); Assert.Equal(source.NewVersion?.Data, snapshot.Data); Assert.Equal(source.CurrentVersion.Data, snapshot.DraftData); @@ -77,7 +76,7 @@ public async Task Should_map_content_with_new_version_to_published() var source = CreateContentWithNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, AppProvider, default); Assert.Equal(source.CurrentVersion?.Data, snapshot.Data); Assert.Null(snapshot.DraftData); @@ -86,10 +85,8 @@ public async Task Should_map_content_with_new_version_to_published() Assert.False(snapshot.IsSnapshot); } - private static ContentDomainObject.State CreateContentWithoutNewVersion() + private ContentDomainObject.State CreateContentWithoutNewVersion() { - var user = RefToken.User("1"); - var data = new ContentData() .AddField("my-field", @@ -101,25 +98,23 @@ private static ContentDomainObject.State CreateContentWithoutNewVersion() var state = new ContentDomainObject.State { Id = DomainId.NewGuid(), - AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + AppId = AppId, Created = time, - CreatedBy = user, + CreatedBy = User, CurrentVersion = new ContentVersion(Status.Archived, data), IsDeleted = true, LastModified = time, - LastModifiedBy = user, - ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, user, time), - SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"), + LastModifiedBy = User, + ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, User, time), + SchemaId = SchemaId, Version = 42 }; return state; } - private static ContentDomainObject.State CreateContentWithNewVersion() + private ContentDomainObject.State CreateContentWithNewVersion() { - var user = RefToken.User("1"); - var data = new ContentData() .AddField("my-field", @@ -137,16 +132,16 @@ private static ContentDomainObject.State CreateContentWithNewVersion() var state = new ContentDomainObject.State { Id = DomainId.NewGuid(), - AppId = NamedId.Of(DomainId.NewGuid(), "my-app"), + AppId = AppId, Created = time, - CreatedBy = user, + CreatedBy = User, CurrentVersion = new ContentVersion(Status.Archived, data), IsDeleted = true, LastModified = time, - LastModifiedBy = user, + LastModifiedBy = User, NewVersion = new ContentVersion(Status.Published, newData), - ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, user, time), - SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"), + ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, User, time), + SchemaId = SchemaId, Version = 42 }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs index 2a190be20c..d614404c4a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs @@ -35,13 +35,14 @@ public async Task Should_add_cache_headers() Assert.Equal(new List { + "X-Fields", "X-Flatten", "X-Languages", "X-NoCleanup", "X-NoEnrichment", "X-NoResolveLanguages", "X-ResolveFlow", - "X-Resolve-Urls", + "X-ResolveUrls", "X-Unpublished" }, headers); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs index 8feee9315c..3260707c82 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs @@ -27,6 +27,11 @@ public static Q HasIds(this INegatableArgumentConstraintManager that, params return that.Matches(x => x.Ids != null && x.Ids.SetEquals(ids)); } + public static Q HasFields(this INegatableArgumentConstraintManager that, IEnumerable fields) + { + return that.Matches(x => x.Fields != null && x.Fields.SetEquals(fields.ToHashSet())); + } + public static Q HasIds(this INegatableArgumentConstraintManager that, IEnumerable ids) { return that.Matches(x => x.Ids != null && x.Ids.SetEquals(ids.ToHashSet())); diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorIntegrationTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorIntegrationTests.cs index a5922aad11..7b2abe24d5 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorIntegrationTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorIntegrationTests.cs @@ -115,10 +115,12 @@ async Task StartStop() for (var j = 0; j < numEvents; j++) { +#pragma warning disable MA0040 // Forward the CancellationToken parameter to methods that take one await persistence.WriteEventsAsync(new List> { Envelope.Create(new MyEvent()) }); +#pragma warning restore MA0040 // Forward the CancellationToken parameter to methods that take one } }); diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs index 2a4bf7d0b1..0cd4cd5939 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs @@ -246,7 +246,9 @@ public async Task Should_subscribe_with_parallel_writes() CreateEventData(i * j) }; +#pragma warning disable MA0040 // Forward the CancellationToken parameter to methods that take one await Sut.AppendAsync(Guid.NewGuid(), fullStreamName, EtagVersion.Any, commit1); +#pragma warning restore MA0040 // Forward the CancellationToken parameter to methods that take one } }); }); diff --git a/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs index 4dba900a78..265f801fd0 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs @@ -7,12 +7,14 @@ using System.Collections.Concurrent; +#pragma warning disable MA0040 // Forward the CancellationToken parameter to methods that take one + namespace Squidex.Infrastructure.Tasks; public class SchedulerTests { private readonly ConcurrentBag actuals = new ConcurrentBag(); - private Scheduler sut = new Scheduler(); + private readonly Scheduler sut = new Scheduler(); [Fact] public async Task Should_schedule_single_task() diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs index 2ff5153e74..0cbe2c6175 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs @@ -99,7 +99,7 @@ public async Task Should_resolve_schema_if_schema_not_published() await sut.OnActionExecutionAsync(actionExecutingContext, next); - AssertSchema(Schema); + AssertSchema(); } [Fact] @@ -125,7 +125,7 @@ public async Task Should_resolve_schema_from_id() await sut.OnActionExecutionAsync(actionExecutingContext, next); - AssertSchema(Schema); + AssertSchema(); } [Fact] @@ -140,7 +140,7 @@ public async Task Should_resolve_schema_from_id_without_caching_if_frontend() await sut.OnActionExecutionAsync(actionExecutingContext, next); - AssertSchema(Schema); + AssertSchema(); } [Fact] @@ -153,7 +153,7 @@ public async Task Should_resolve_schema_from_name() await sut.OnActionExecutionAsync(actionExecutingContext, next); - AssertSchema(Schema); + AssertSchema(); } [Fact] @@ -168,7 +168,7 @@ public async Task Should_resolve_schema_from_name_without_caching_if_frontend() await sut.OnActionExecutionAsync(actionExecutingContext, next); - AssertSchema(Schema); + AssertSchema(); } [Fact] @@ -202,7 +202,7 @@ private void AssertNotFound() Assert.False(isNextCalled); } - private void AssertSchema(ISchemaEntity schema) + private void AssertSchema() { Assert.Equal(Schema, actionContext.HttpContext.Features.Get()!.Schema); Assert.True(isNextCalled); diff --git a/frontend/src/app/features/content/pages/contents/contents-page.component.html b/frontend/src/app/features/content/pages/contents/contents-page.component.html index acbd6b441d..86bce4e931 100644 --- a/frontend/src/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/src/app/features/content/pages/contents/contents-page.component.html @@ -40,108 +40,110 @@ - - - -
- {{ 'contents.selectionCount' | sqxTranslate: { count: selectionCount } }}   - - - - -
+ + + + +
+ {{ 'contents.selectionCount' | sqxTranslate: { count: selectionCount } }}   + + + + +
-
- +
+ - - - - - - -
- + + + + + + +
+
- - - - -
-
- + + + + + - - + + - - - -
+
+ - -
-
- {{ 'common.actions' | sqxTranslate }} - - + + + {{ 'common.actions' | sqxTranslate }} + - -
-
- - -
- - - + [fields]="tableSettings"> + + + + + +
-
-
+ + + +
+ + + +
+
+
- - - - - - + + + + + + +
diff --git a/frontend/src/app/features/content/pages/contents/contents-page.component.ts b/frontend/src/app/features/content/pages/contents/contents-page.component.ts index 40ff2b6276..f9ead03bad 100644 --- a/frontend/src/app/features/content/pages/contents/contents-page.component.ts +++ b/frontend/src/app/features/content/pages/contents/contents-page.component.ts @@ -9,8 +9,9 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; -import { AppLanguageDto, AppsState, ContentDto, ContentsState, contentsTranslationStatus, ContributorsState, defined, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, TranslationStatus, UIState } from '@app/shared'; +import { combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, map, shareReplay, switchMap, take, tap } from 'rxjs/operators'; +import { AppLanguageDto, AppsState, ContentDto, ContentsService, ContentsState, contentsTranslationStatus, ContributorsState, defined, isDataField, LanguagesState, LocalStoreService, ModalModel, Queries, Query, QuerySynchronizer, ResourceOwner, Router2State, SchemaDto, SchemasService, SchemasState, Settings, switchSafe, TableSettings, TempService, TranslationStatus, Types, UIState } from '@app/shared'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; @Component({ @@ -27,7 +28,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { public schema!: SchemaDto; - public tableSettings!: TableSettings; + public tableSettings!: Observable; public tableViewModal = new ModalModel(); public searchModal = new ModalModel(); @@ -61,6 +62,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { public readonly languagesState: LanguagesState, private readonly appsState: AppsState, private readonly contributorsState: ContributorsState, + private readonly contentsService: ContentsService, private readonly localStore: LocalStoreService, private readonly route: ActivatedRoute, private readonly router: Router, @@ -89,29 +91,46 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { this.languages = languages; })); - this.own( - getSchemaName(this.route).pipe(switchMap(() => this.schemasState.selectedSchema.pipe(defined(), take(1)))) - .subscribe(schema => { - this.resetSelection(); + const schema$ = + getSchemaName(this.route).pipe(switchMap(() => this.schemasState.selectedSchema.pipe(defined(), take(1), shareReplay(1)))); - this.schema = schema; + const tableSetting$ = + schema$.pipe(map(s => new TableSettings(this.uiState, s)), shareReplay(1)); - const initial = - this.contentsRoute.mapTo(this.contentsState) - .withPaging('contents', 10) - .withSynchronizer(QuerySynchronizer.INSTANCE) - .getInitial(); + const tableField$ = + tableSetting$.pipe(switchMap(s => s.listFields)); - this.contentsState.load(false, true, initial); - this.contentsRoute.listen(); + const tableName$ = + tableField$.pipe(map(t => t.map(x => x.name).filter(isDataField).sorted()), distinctUntilChanged(Types.equals)); - this.tableSettings = new TableSettings(this.uiState, schema); + this.tableSettings = tableSetting$; - const languageKey = this.localStore.get(this.languageKey()); - const language = this.languages.find(x => x.iso2Code === languageKey); - - if (language) { - this.language = language; + this.own( + combineLatest([schema$, tableName$]) + .subscribe(([schema, fieldNames]) => { + if (!this.schema) { + this.resetSelection(); + + this.schema = schema; + + const initial = + this.contentsRoute.mapTo(this.contentsState) + .withPaging('contents', 10) + .withSynchronizer(QuerySynchronizer.INSTANCE) + .getInitial(); + + this.contentsState.load(false, true, { fieldNames, ...initial }); + this.contentsRoute.listen(); + + const languageKey = this.localStore.get(this.languageKey()); + const languageItem = this.languages.find(x => x.iso2Code === languageKey); + + if (languageItem) { + this.language = languageItem; + } + } else { + this.contentsState.setFieldNames(fieldNames); + this.contentsState.load(); } })); @@ -162,9 +181,16 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { } public clone(content: ContentDto) { - this.tempService.put(content.data); + if (!content) { + return; + } + + this.contentsService.getContent(this.contentsState.appName, this.contentsState.schemaName, content.id) + .subscribe(currentContent => { + this.tempService.put(currentContent.data); - this.router.navigate(['new'], { relativeTo: this.route }); + this.router.navigate(['new'], { relativeTo: this.route }); + }); } public changeLanguage(language: AppLanguageDto) { diff --git a/frontend/src/app/features/content/pages/contents/custom-view-editor.component.html b/frontend/src/app/features/content/pages/contents/custom-view-editor.component.html index b67b78ea89..54940f4f4e 100644 --- a/frontend/src/app/features/content/pages/contents/custom-view-editor.component.html +++ b/frontend/src/app/features/content/pages/contents/custom-view-editor.component.html @@ -17,7 +17,7 @@
@@ -33,7 +33,7 @@
diff --git a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html index 679e00808b..a8951611e8 100644 --- a/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html +++ b/frontend/src/app/features/schemas/pages/schema/ui/field-list.component.html @@ -9,8 +9,8 @@ {{emptyText}} -
- {{field.name}} +
+ {{(field.title || field.label) | sqxTranslate}}: {{field.name}}
@@ -22,7 +22,7 @@ (cdkDropListDropped)="drop($event)"> -
- {{field.name}} +
+ {{(field.title || field.label) | sqxTranslate}}: {{field.name}}
\ No newline at end of file diff --git a/frontend/src/app/shared/services/contents.service.ts b/frontend/src/app/shared/services/contents.service.ts index 8a6d57e134..14302e3b53 100644 --- a/frontend/src/app/shared/services/contents.service.ts +++ b/frontend/src/app/shared/services/contents.service.ts @@ -12,7 +12,7 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework'; import { StatusInfo } from './../state/contents.state'; import { Query, sanitize } from './query'; -import { parseField, RootFieldDto } from './schemas.service'; +import { isDataField, parseField, RootFieldDto } from './schemas.service'; export class ScheduleDto { constructor( @@ -163,6 +163,9 @@ export type ContentsQuery = Readonly<{ // True, to not return the total number of items, if the query would be slow. noSlowTotal?: boolean; + + // The field names to query. + fieldNames?: ReadonlyArray; }>; export type ContentsByIds = Readonly<{ @@ -370,6 +373,10 @@ function buildHeaders(q: ContentsQuery | undefined) { headers: {}, }; + if (q?.fieldNames) { + options.headers['X-Fields'] = q.fieldNames.filter(isDataField).join(','); + } + if (q?.noTotal) { options.headers['X-NoTotal'] = '1'; } diff --git a/frontend/src/app/shared/services/schemas.service.ts b/frontend/src/app/shared/services/schemas.service.ts index 86c98a76d5..6cb7db6f38 100644 --- a/frontend/src/app/shared/services/schemas.service.ts +++ b/frontend/src/app/shared/services/schemas.service.ts @@ -22,61 +22,79 @@ export const META_FIELDS = { empty: { name: '', label: '', + title: '', }, id: { name: 'meta.id', label: 'i18n:schemas.tableHeaders.id', + title: 'i18n:schemas.tableHeaders.id_title', }, created: { name: 'meta.created', label: 'i18n:schemas.tableHeaders.created', + title: 'i18n:schemas.tableHeaders.created_title', }, createdByAvatar: { name: 'meta.createdBy.avatar', label: 'i18n:schemas.tableHeaders.createdByShort', + title: 'i18n:schemas.tableHeaders.createdByShort_title', }, createdByName: { name: 'meta.createdBy.name', label: 'i18n:schemas.tableHeaders.createdBy', + title: 'i18n:schemas.tableHeaders.createdBy_title', }, lastModified: { name: 'meta.lastModified', label: 'i18n:schemas.tableHeaders.lastModified', + title: 'i18n:schemas.tableHeaders.lastModified_title', }, lastModifiedByAvatar: { name: 'meta.lastModifiedBy.avatar', label: 'i18n:schemas.tableHeaders.lastModifiedByShort', + title: 'i18n:schemas.tableHeaders.lastModifiedByShort_title', }, lastModifiedByName: { name: 'meta.lastModifiedBy.name', label: 'i18n:schemas.tableHeaders.lastModifiedBy', + title: 'i18n:schemas.tableHeaders.lastModifiedBy_title', }, status: { name: 'meta.status', label: 'i18n:schemas.tableHeaders.status', + title: 'i18n:schemas.tableHeaders.status_title', }, statusColor: { name: 'meta.status.color', label: 'i18n:schemas.tableHeaders.status', + title: 'i18n:schemas.tableHeaders.status_title', }, statusNext: { name: 'meta.status.next', label: 'i18n:schemas.tableHeaders.nextStatus', + title: 'i18n:schemas.tableHeaders.nextStatus_title', }, version: { name: 'meta.version', label: 'i18n:schemas.tableHeaders.version', + title: 'i18n:schemas.tableHeaders.version_title', }, translationStatus: { name: 'meta.translationStatus', label: 'i18n:schemas.tableHeaders.translationStatus', + title: 'i18n:schemas.tableHeaders.translationStatus_title', }, translationStatusAverage: { name: 'meta.translationStatusAverage', label: 'i18n:schemas.tableHeaders.translationStatusAverage', + title: 'i18n:schemas.tableHeaders.translationStatusAverage_title', }, }; +export function isDataField(name: string) { + return name.indexOf('meta.') < 0; +} + export const FIELD_RULE_ACTIONS: ReadonlyArray = [ 'Disable', 'Hide', @@ -151,7 +169,9 @@ export class SchemaDto { this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); function tableField(rootField: RootFieldDto) { - return { name: rootField.name, label: rootField.displayName, rootField }; + const label = rootField.displayName; + + return { name: rootField.name, label, rootField }; } if (fields) { @@ -363,6 +383,9 @@ export type TableField = Readonly<{ // The label for the table header. label: string; + // The title. + title?: string; + // The reference to the root field. rootField?: RootFieldDto; }>; diff --git a/frontend/src/app/shared/state/contents.state.ts b/frontend/src/app/shared/state/contents.state.ts index a9a4672dec..b2a23568f3 100644 --- a/frontend/src/app/shared/state/contents.state.ts +++ b/frontend/src/app/shared/state/contents.state.ts @@ -24,6 +24,9 @@ interface Snapshot extends ListState { // The current contents. contents: ReadonlyArray; + // The field names to select. + fieldNames: ReadonlyArray | null; + // The referencing content id. referencing?: string; @@ -98,6 +101,7 @@ export abstract class ContentsStateBase extends State { ) { super({ contents: [], + fieldNames: null, page: 0, pageSize: 10, total: 0, @@ -119,14 +123,7 @@ export abstract class ContentsStateBase extends State { private loadContent(id: string | null) { return !id ? of(null) : - of(this.snapshot.contents.find(x => x.id === id)).pipe( - switchMap(content => { - if (!content) { - return this.contentsService.getContent(this.appName, this.schemaName, id).pipe(catchError(() => of(null))); - } else { - return of(content); - } - })); + this.contentsService.getContent(this.appName, this.schemaName, id).pipe(catchError(() => of(null))); } public loadReferences(contentId: string, update: Partial = {}) { @@ -143,7 +140,7 @@ export abstract class ContentsStateBase extends State { public load(isReload = false, noSlowTotal = true, update: Partial = {}): Observable { if (!isReload) { - this.resetState({ selectedContent: this.snapshot.selectedContent, ...update }, 'Loading Intial'); + this.resetState({ selectedContent: this.snapshot.selectedContent, fieldNames: this.snapshot.fieldNames, ...update }, 'Loading Intial'); } return this.loadInternal(isReload, noSlowTotal); @@ -157,6 +154,10 @@ export abstract class ContentsStateBase extends State { return this.loadInternal(false, true); } + public setFieldNames(fieldNames: ReadonlyArray | null) { + this.next( { fieldNames }, 'Set field names.'); + } + private loadInternal(isReload: boolean, noSlowTotal: boolean) { return this.loadInternalCore(isReload, noSlowTotal).pipe(shareSubscribed(this.dialogs)); } @@ -480,13 +481,14 @@ function getStatusQueries(statuses: ReadonlyArray | undefined): Read function createQuery(snapshot: Snapshot, noSlowTotal: boolean) { const { + fieldNames, page, pageSize, query, total, } = snapshot; - const result: any = { take: pageSize, skip: pageSize * page, noSlowTotal }; + const result: any = { take: pageSize, skip: pageSize * page, noSlowTotal, fieldNames }; if (query) { result.query = query; diff --git a/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs b/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs index 336e61b3b5..cbf89120d4 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs @@ -170,13 +170,12 @@ public async Task Should_delete_language() // Fallback language must be removed. Assert.Empty(language_2_IT.Fallback); - Assert.Equal(new string[] { "en", "it" }, languages_2.Items.Select(x => x.Iso2Code).ToArray()); await Verify(languages_2); } - private async Task AddLanguageAsync(ISquidexClient app, string code) + private static async Task AddLanguageAsync(ISquidexClient app, string code) { var createRequest = new AddLanguageDto { diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index e275902fef..efba03c850 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -352,7 +352,7 @@ public async Task Should_annotate_asset_in_parallel() } }; - await _.Client.Assets.PutAssetAsync(asset_1.Id, randomMetadataRequest); + await _.Client.Assets.PutAssetAsync(asset_1.Id, randomMetadataRequest, ct); } catch (SquidexException ex) when (ex.StatusCode is 409 or 412) { diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index 48ae5f5ab7..4cc8ccb75d 100644 --- a/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -733,7 +733,7 @@ public async Task Should_update_content_in_parallel() await _.Contents.UpdateAsync(content.Id, new TestEntityData { Number = i - }); + }, ct: ct); } catch (SquidexException ex) when (ex.StatusCode is 409 or 412) { @@ -771,7 +771,7 @@ public async Task Should_upsert_content_in_parallel() await _.Contents.UpsertAsync(content.Id, new TestEntityData { Number = i - }); + }, ct: ct); } catch (SquidexException ex) when (ex.StatusCode is 409 or 412) { diff --git a/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs b/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs index 71af30a81c..31f932dd52 100644 --- a/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs @@ -336,13 +336,14 @@ public async Task Should_return_correct_vary_headers() Assert.Equal(new string[] { "Auth-State", + "X-Fields", "X-Flatten", "X-Languages", "X-NoCleanup", "X-NoEnrichment", "X-NoResolveLanguages", - "X-Resolve-Urls", "X-ResolveFlow", + "X-ResolveUrls", "X-Unpublished" }, response.Headers.Vary.Order().ToArray()); } diff --git a/tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs b/tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs index b4d710aeaf..6f3b2deed1 100644 --- a/tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/RuleEventsTests.cs @@ -93,8 +93,6 @@ public async Task Should_cancel_event_by_app() private async Task<(ISquidexClient App, RuleDto)> CreateAppAndRuleAsync() { - var appName = Guid.NewGuid().ToString(); - var (app, _) = await _.PostAppAsync(); var createRule = new CreateRuleDto diff --git a/tools/TestSuite/TestSuite.Shared/Strategies.cs b/tools/TestSuite/TestSuite.Shared/Strategies.cs index 1f528aaebc..82899316d1 100644 --- a/tools/TestSuite/TestSuite.Shared/Strategies.cs +++ b/tools/TestSuite/TestSuite.Shared/Strategies.cs @@ -227,7 +227,7 @@ public static Task PatchAsync(this ISquidexClient client, ContentBase content, o } } - private class MyContent : Content + private sealed class MyContent : Content { } }