diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs new file mode 100644 index 0000000000..fc7677dcdc --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents; + +public static class IndexParser +{ + public static bool TryParse(BsonDocument source, string prefix, [MaybeNullWhen(false)] out IndexDefinition index) + { + index = null!; + + if (!source.TryGetValue("name", out var name) || name.BsonType != BsonType.String) + { + return false; + } + + if (!name.AsString.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + if (!source.TryGetValue("key", out var keys) || keys.BsonType != BsonType.Document) + { + return false; + } + + var definition = new IndexDefinition(); + foreach (var property in keys.AsBsonDocument) + { + if (property.Value.BsonType != BsonType.Int32) + { + return false; + } + + var fieldName = Adapt.MapPathReverse(property.Name).ToString(); + + var order = property.Value.AsInt32 < 0 ? + SortOrder.Descending : + SortOrder.Ascending; + + definition.Add(new IndexField(fieldName, order)); + } + + if (definition.Count == 0) + { + return false; + } + + index = definition; + return true; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 59bcf7abb0..e25ddc8977 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -318,4 +319,33 @@ public async Task AddCollectionsAsync(MongoContentEntity entity, Action> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + if (queryInDedicatedCollection != null) + { + return await queryInDedicatedCollection.GetIndexesAsync(appId, schemaId, ct); + } + + return []; + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index e171ef434e..3006e4ab95 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -20,6 +20,7 @@ using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents; @@ -130,6 +131,24 @@ public Task ResetScheduledAsync(DomainId appId, DomainId contentId, SearchScope return GetCollection(SearchScope.All).ResetScheduledAsync(appId, contentId, ct); } + public Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).CreateIndexAsync(appId, schemaId, index, ct); + } + + public Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).GetIndexesAsync(appId, schemaId, ct); + } + + public Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).DropIndexAsync(appId, schemaId, name, ct); + } + private MongoContentCollection GetCollection(SearchScope scope) { return scope == SearchScope.All ? collectionComplete : collectionPublished; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs index a3a48e1cf8..542256041c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Runtime.CompilerServices; +using System.Xml.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -79,6 +80,24 @@ public IAsyncEnumerable StreamReferencing(DomainId appId, DomainId refe return Shard(appId).StreamReferencing(appId, references, take, scope, ct); } + public Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct = default) + { + return Shard(appId).CreateIndexAsync(appId, schemaId, index, ct); + } + + public Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct = default) + { + return Shard(appId).DropIndexAsync(appId, schemaId, name, ct); + } + + public Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + return Shard(appId).GetIndexesAsync(appId, schemaId, ct); + } + public async IAsyncEnumerable StreamScheduledWithoutDataAsync(Instant now, SearchScope scope, [EnumeratorCancellation] CancellationToken ct = default) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs index ce4c3a9404..ff4b2984e2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using GraphQL; using MongoDB.Bson.Serialization; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -16,7 +17,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; public static class Adapt { private static Dictionary pathMap; + private static Dictionary pathReverseMap; private static Dictionary propertyMap; + private static Dictionary propertyReverseMap; public static IReadOnlyDictionary PropertyMap { @@ -28,13 +31,29 @@ public static IReadOnlyDictionary PropertyMap StringComparer.OrdinalIgnoreCase); } + public static IReadOnlyDictionary PropertyReverseMap + { + get => propertyReverseMap ??= + BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).AllMemberMaps + .ToDictionary( + x => x.ElementName, + x => x.MemberName.ToCamelCase(), + StringComparer.OrdinalIgnoreCase); + } + public static IReadOnlyDictionary PathMap { get => pathMap ??= PropertyMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value); } + public static IReadOnlyDictionary PathReverseMap + { + get => pathReverseMap ??= PropertyReverseMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value); + } + public static PropertyPath MapPath(PropertyPath path) { + // Shortcut to prevent allocations for most used field names. if (path.Count == 1 && PathMap.TryGetValue(path[0], out var mappedPath)) { return mappedPath; @@ -52,12 +71,40 @@ public static PropertyPath MapPath(PropertyPath path) for (var i = 1; i < path.Count; i++) { + // MongoDB does not accept all field names. result[i] = result[i].UnescapeEdmField().JsonToBsonName().JsonEscape(); } return result; } + public static PropertyPath MapPathReverse(PropertyPath path) + { + // Shortcut to prevent allocations for most used field names. + if (path.Count == 1 && PathReverseMap.TryGetValue(path[0], out var mappedPath)) + { + return mappedPath; + } + + var result = new List(path); + + if (result.Count > 0) + { + if (PropertyReverseMap.TryGetValue(path[0], out var mapped)) + { + result[0] = mapped; + } + } + + for (var i = 1; i < path.Count; i++) + { + // MongoDB does not accept all field names. + result[i] = result[i].EscapeEdmField().BsonToJsonName().JsonUnescape().ToCamelCase(); + } + + return result; + } + public static ClrQuery AdjustToModel(this ClrQuery query, DomainId appId) { if (query.Filter != null) 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 6f1886bfb6..72d5de7b52 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 @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Concurrent; +using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -141,6 +142,63 @@ public async Task RemoveAsync(IClientSessionHandle session, MongoContentEntity v await collection.DeleteOneAsync(session, x => x.DocumentId == value.DocumentId, null, ct); } + public async Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct) + { + var collection = await GetCollectionAsync(appId, schemaId); + + await collection.Indexes.DropOneAsync(name, ct); + } + + public async Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + var result = new List(); + + var collection = await GetCollectionAsync(appId, schemaId); + var colIndexes = await collection.Indexes.ListAsync(ct); + + foreach (var index in await colIndexes.ToListAsync(ct)) + { + if (IndexParser.TryParse(index, "custom_", out var definition)) + { + result.Add(definition); + } + } + + return result; + } + + public async Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct) + { + var collection = await GetCollectionAsync(appId, schemaId); + + var definition = Index.Combine( + index.Select(field => + { + var path = Adapt.MapPath(field.Name).ToString(); + + if (field.Order == SortOrder.Ascending) + { + return Index.Ascending(path); + } + + return Index.Descending(path); + })); + + var name = $"custom_{index.ToName()}"; + + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + definition, + new CreateIndexOptions + { + Name = name, + }), + cancellationToken: ct); + } + private static FilterDefinition BuildFilter(FilterNode? filter) { var filters = new List> diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs index 6c6930863b..83c07bfd2b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs @@ -50,6 +50,9 @@ public BackupJob( public static JobRequest BuildRequest(RefToken actor, App app) { + Guard.NotNull(actor); + Guard.NotNull(app); + return JobRequest.Create( actor, TaskName, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs index 96298ab5c7..889544b748 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs @@ -79,6 +79,9 @@ public RestoreJob( public static JobRequest BuildRequest(RefToken actor, Uri url, string? appName) { + Guard.NotNull(actor); + Guard.NotNull(url); + return JobRequest.Create( actor, TaskName, @@ -92,9 +95,14 @@ public static JobRequest BuildRequest(RefToken actor, Uri url, string? appName) public async Task RunAsync(JobRunContext context, CancellationToken ct) { - if (!context.Job.Arguments.TryGetValue(ArgUrl, out var urlValue) || !Uri.TryCreate(urlValue, UriKind.Absolute, out var url)) + if (!context.Job.Arguments.TryGetValue(ArgUrl, out var urlValue)) + { + throw new DomainException($"Argument '{ArgUrl}' missing."); + } + + if (!Uri.TryCreate(urlValue, UriKind.Absolute, out var url)) { - throw new DomainException("Argument missing."); + throw new DomainException($"Argument '{ArgUrl}' is not a valid URL."); } var state = new State diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs new file mode 100644 index 0000000000..1294124601 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public sealed class CreateIndexJob : IJobRunner +{ + public const string TaskName = "createIndex"; + public const string ArgAppId = "appId"; + public const string ArgAppName = "appName"; + public const string ArgSchemaId = "schemaId"; + public const string ArgSchemaName = "schemaName"; + public const string ArgFieldName = "field_"; + private readonly IContentRepository contentRepository; + + public string Name => TaskName; + + public CreateIndexJob(IContentRepository contentRepository) + { + this.contentRepository = contentRepository; + } + + public static JobRequest BuildRequest(RefToken actor, App app, Schema schema, IndexDefinition index) + { + Guard.NotNull(actor); + Guard.NotNull(app); + Guard.NotNull(schema); + Guard.NotNull(index); + + var args = new Dictionary + { + [ArgAppId] = app.Id.ToString(), + [ArgAppName] = app.Name, + [ArgSchemaId] = schema.Id.ToString(), + [ArgSchemaName] = schema.Name + }; + + foreach (var field in index) + { + args[$"{ArgFieldName}{field.Name}"] = field.Order.ToString(); + } + + return JobRequest.Create( + actor, + TaskName, + args) with + { + AppId = app.NamedId() + }; + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + // The other arguments are just there for debugging purposes. Therefore do not validate them. + if (!context.Job.Arguments.TryGetValue(ArgSchemaId, out var schemaId)) + { + throw new DomainException($"Argument '{ArgSchemaId}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgSchemaName, out var schemaName)) + { + throw new DomainException($"Argument '{ArgSchemaName}' missing."); + } + + var index = new IndexDefinition(); + + foreach (var (arg, value) in context.Job.Arguments) + { + if (!arg.StartsWith(ArgFieldName, StringComparison.Ordinal)) + { + continue; + } + + var field = arg[ArgFieldName.Length..]; + + if (!Enum.TryParse(value, out var order)) + { + throw new DomainException($"Invalid sort order {order} for field {field}."); + } + + index.Add(new IndexField(field, order)); + } + + if (index.Count == 0) + { + throw new DomainException("Index does not contain an field."); + } + + // Use a readable name to describe the job. + context.Job.Description = $"Schema {schemaName}: Create index {index.ToName()}"; + + await contentRepository.CreateIndexAsync(context.OwnerId, DomainId.Create(schemaId), index, ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs new file mode 100644 index 0000000000..63a823eabe --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public sealed class DropIndexJob : IJobRunner +{ + public const string TaskName = "dropIndex"; + public const string ArgAppId = "appId"; + public const string ArgAppName = "appName"; + public const string ArgSchemaId = "schemaId"; + public const string ArgSchemaName = "schemaName"; + public const string ArgIndexName = "indexName"; + private readonly IContentRepository contentRepository; + + public string Name => TaskName; + + public DropIndexJob(IContentRepository contentRepository) + { + this.contentRepository = contentRepository; + } + + public static JobRequest BuildRequest(RefToken actor, App app, Schema schema, string name) + { + Guard.NotNull(actor); + Guard.NotNull(app); + Guard.NotNull(schema); + Guard.NotNullOrEmpty(name); + + return JobRequest.Create( + actor, + TaskName, + new Dictionary + { + [ArgAppId] = app.Id.ToString(), + [ArgAppName] = app.Name, + [ArgSchemaId] = schema.Id.ToString(), + [ArgSchemaName] = schema.Name, + [ArgIndexName] = name + }) with + { + AppId = app.NamedId() + }; + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + // The other arguments are just there for debugging purposes. Therefore do not validate them. + if (!context.Job.Arguments.TryGetValue(ArgSchemaId, out var schemaId)) + { + throw new DomainException($"Argument '{ArgSchemaId}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgSchemaName, out var schemaName)) + { + throw new DomainException($"Argument '{ArgSchemaName}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgIndexName, out var indexName)) + { + throw new DomainException($"Argument '{ArgIndexName}' missing."); + } + + // Use a readable name to describe the job. + context.Job.Description = $"Schema {schemaName}: Drop index {indexName}"; + + await contentRepository.DropIndexAsync(context.OwnerId, DomainId.Create(schemaId), indexName, ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index c3ea96fe5f..0d4ee612c1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents.Repositories; @@ -45,4 +46,13 @@ Task HasReferrersAsync(App app, DomainId reference, SearchScope scope, Task ResetScheduledAsync(DomainId appId, DomainId contentId, SearchScope scope, CancellationToken ct = default); + + Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct = default); + + Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct = default); + + Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs index fb57e0c8bf..bc15d81c9e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Amazon.Runtime.Internal.Endpoints.StandardLibrary; using Microsoft.Extensions.Logging; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.HandleRules; @@ -69,6 +70,9 @@ public RuleRunnerJob( public static JobRequest BuildRequest(RefToken actor, App app, DomainId ruleId, bool snapshot) { + Guard.NotNull(actor); + Guard.NotNull(app); + return JobRequest.Create( actor, TaskName, @@ -87,16 +91,17 @@ public async Task RunAsync(JobRunContext context, { if (!context.Job.Arguments.TryGetValue(ArgRuleId, out var ruleId)) { - throw new DomainException("Argument missing."); + throw new DomainException($"Argument '{ArgRuleId}' missing."); } var rule = await appProvider.GetRuleAsync(context.OwnerId, DomainId.Create(ruleId), ct) ?? throw new DomainObjectNotFoundException(ruleId); - var fromSnapshot = string.Equals(context.Job.Arguments.GetValueOrDefault(ArgSnapshot), "true", StringComparison.OrdinalIgnoreCase); + var fromSnapshotArg = context.Job.Arguments.GetValueOrDefault(ArgSnapshot); + var fromSnapshotValue = string.Equals(fromSnapshotArg, "true", StringComparison.OrdinalIgnoreCase); // Use a readable name to describe the job. - SetDescription(context, rule, fromSnapshot); + SetDescription(context, rule, fromSnapshotValue); // Also run disabled rules, because we want to enable rules to be only used with manual trigger. var ruleContext = new RuleContext @@ -107,7 +112,7 @@ public async Task RunAsync(JobRunContext context, Rule = rule, }; - if (fromSnapshot && ruleService.CanCreateSnapshotEvents(rule)) + if (fromSnapshotValue && ruleService.CanCreateSnapshotEvents(rule)) { await EnqueueFromSnapshotsAsync(ruleContext, ct); } @@ -166,7 +171,8 @@ private async Task EnqueueFromSnapshotsAsync(RuleContext context, throw result.EnrichmentError; } - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", + result.Rule?.Id); } } } @@ -206,7 +212,8 @@ private async Task EnqueueFromEventsAsync(JobRunContext run, RuleContext context throw result.EnrichmentError; } - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", + result.Rule?.Id); } } } diff --git a/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs b/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs new file mode 100644 index 0000000000..e8d5686595 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using Squidex.Infrastructure.Queries; + +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Infrastructure.States; + +public sealed class IndexDefinition : List +{ + public string ToName() + { + var sb = new StringBuilder(); + + foreach (var field in this) + { + if (sb.Length > 0) + { + sb.Append('_'); + } + + sb.Append(field.Name); + sb.Append('_'); + + if (field.Order == SortOrder.Ascending) + { + sb.Append("asc"); + } + else + { + sb.Append("desc"); + } + } + + return sb.ToString(); + } +} + +public sealed record IndexField(string Name, SortOrder Order); diff --git a/backend/src/Squidex.Infrastructure/StringExtensions.cs b/backend/src/Squidex.Infrastructure/StringExtensions.cs index 40504f98dc..e7a02a2924 100644 --- a/backend/src/Squidex.Infrastructure/StringExtensions.cs +++ b/backend/src/Squidex.Infrastructure/StringExtensions.cs @@ -30,6 +30,13 @@ public static string JsonEscape(this string value) return value; } + public static string JsonUnescape(this string value) + { + value = JsonSerializer.Deserialize($"\"{value}\"", JsonEscapeOptions)!; + + return value; + } + public static bool IsEmail(this string? value) { return value != null && RegexEmail.IsMatch(value); diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index d5f3d9a907..dae8a43fe3 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -204,6 +204,7 @@ public static class PermissionIds public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{schema}.scripts"; public const string AppSchemasPublish = "squidex.apps.{app}.schemas.{schema}.publish"; public const string AppSchemasDelete = "squidex.apps.{app}.schemas.{schema}.delete"; + public const string AppSchemasIndexes = "squidex.apps.{app}.schemas.{schema}.indexes"; // App Contents public const string AppContents = "squidex.apps.{app}.contents.{schema}"; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index ef29367118..de13d53e31 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -44,6 +44,8 @@ public sealed class Resources public bool CanDeleteSchema(string schema) => Can(PermissionIds.AppSchemasDelete, schema); + public bool CanManageIndexes(string schema) => Can(PermissionIds.AppSchemasIndexes, schema); + public bool CanCreateSchema => Can(PermissionIds.AppSchemasCreate); public bool CanUpdateSettings => Can(PermissionIds.AppUpdateSettings); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index 8113461584..47a844185f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -67,7 +67,7 @@ public IActionResult GetClients(string app) /// [HttpPost] [Route("apps/{app}/clients/")] - [ProducesResponseType(typeof(ClientsDto), 201)] + [ProducesResponseType(typeof(ClientsDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppClientsCreate)] [ApiCosts(1)] public async Task PostClient(string app, [FromBody] CreateClientDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 312b9c2aa8..fd2e9747ef 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -61,7 +61,7 @@ public IActionResult GetLanguages(string app) /// App not found. [HttpPost] [Route("apps/{app}/languages/")] - [ProducesResponseType(typeof(AppLanguagesDto), 201)] + [ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppLanguagesCreate)] [ApiCosts(1)] public async Task PostLanguage(string app, [FromBody] AddLanguageDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs index ada0c5464a..5c66e9fcd0 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -88,7 +88,7 @@ public IActionResult GetPermissions(string app) /// App not found. [HttpPost] [Route("apps/{app}/roles/")] - [ProducesResponseType(typeof(RolesDto), 201)] + [ProducesResponseType(typeof(RolesDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppRolesCreate)] [ApiCosts(1)] public async Task PostRole(string app, [FromBody] AddRoleDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 2c8fdfb371..95e29c0f69 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -128,7 +128,7 @@ public IActionResult GetApp(string app) /// [HttpPost] [Route("apps/")] - [ProducesResponseType(typeof(AppDto), 201)] + [ProducesResponseType(typeof(AppDto), StatusCodes.Status201Created)] [ApiPermission] [ApiCosts(0)] public async Task PostApp([FromBody] CreateAppDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index 30916a0970..53e9336fe7 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -112,7 +112,7 @@ public async Task GetRules(string app) /// App not found. [HttpPost] [Route("apps/{app}/rules/")] - [ProducesResponseType(typeof(RuleDto), 201)] + [ProducesResponseType(typeof(RuleDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppRulesCreate)] [ApiCosts(1)] public async Task PostRule(string app, [FromBody] CreateRuleDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs new file mode 100644 index 0000000000..ffc33549d9 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +[OpenApiRequest] +public sealed class CreateIndexDto +{ + /// + /// The index fields. + /// + [LocalizedRequired] + public List Fields { get; set; } + + public IndexDefinition ToIndex() + { + var result = new IndexDefinition(); + + foreach (var field in Fields) + { + result.Add(new IndexField(field.Name, field.Order)); + } + + return result; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs new file mode 100644 index 0000000000..e5d65628ab --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexDto : Resource +{ + /// + /// The name of the index. + /// + [LocalizedRequired] + public string Name { get; set; } + + /// + /// The index fields. + /// + [LocalizedRequired] + public List Fields { get; set; } + + public static IndexDto FromDomain(IndexDefinition index, Resources resources) + { + var result = new IndexDto + { + Name = index.ToName(), + Fields = index.Select(IndexFieldDto.FromDomain).ToList(), + }; + + return result.CreateLinks(resources); + } + + private IndexDto CreateLinks(Resources resources) + { + var values = new { app = resources.App, schema = resources.Schema, name = Name }; + + if (resources.CanManageIndexes(resources.Schema!)) + { + AddDeleteLink("delete", + resources.Url(x => nameof(x.DeleteIndex), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs new file mode 100644 index 0000000000..4cbbe85843 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexFieldDto +{ + /// + /// The name of the field. + /// + [LocalizedRequired] + public string Name { get; set; } + + /// + /// The sort order of the field. + /// + public SortOrder Order { get; set; } + + public static IndexFieldDto FromDomain(IndexField field) + { + return SimpleMapper.Map(field, new IndexFieldDto()); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs new file mode 100644 index 0000000000..2a12c8f568 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexesDto : Resource +{ + /// + /// The indexes. + /// + public IndexDto[] Items { get; set; } + + public static IndexesDto FromDomain(List indexes, Resources resources) + { + var result = new IndexesDto + { + Items = indexes.Select(x => IndexDto.FromDomain(x, resources)).ToArray() + }; + + return result.CreateLinks(resources); + } + + private IndexesDto CreateLinks(Resources resources) + { + var values = new { app = resources.App, schema = resources.Schema }; + + AddSelfLink(resources.Url(x => nameof(x.GetIndexes), values)); + + if (resources.CanManageIndexes(resources.Schema!)) + { + AddPostLink("create", + resources.Url(x => nameof(x.PostIndex), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 18eca20b21..5a4520d5e2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -159,6 +159,12 @@ protected virtual void CreateLinks(Resources resources) resources.Url(x => nameof(x.GetContents), values)); } + if (resources.CanManageIndexes(Name) && Type == SchemaType.Default) + { + AddGetLink("indexes", + resources.Url(x => nameof(x.GetIndexes), values)); + } + if (resources.CanCreateContent(Name) && Type == SchemaType.Default) { AddPostLink("contents/create", diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index 3a8cd1239d..2a89c5298f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -38,7 +38,7 @@ public SchemaFieldsController(ICommandBus commandBus) /// Schema field name already in use. [HttpPost] [Route("apps/{app}/schemas/{schema}/fields/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostField(string app, string schema, [FromBody] AddFieldDto request) @@ -63,7 +63,7 @@ public async Task PostField(string app, string schema, [FromBody] /// Schema, field or app not found. [HttpPost] [Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostNestedField(string app, string schema, long parentId, [FromBody] AddFieldDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs new file mode 100644 index 0000000000..ec2a239176 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Controllers.Schemas.Models; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Indexes; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas; + +/// +/// Update and query information about schemas. +/// +[ApiExplorerSettings(GroupName = nameof(Schemas))] +[ApiModelValidation(true)] +public class SchemaIndexesController : ApiController +{ + private readonly ICommandBus commandBus; + private readonly IJobService jobService; + private readonly IContentRepository contentRepository; + + public SchemaIndexesController(ICommandBus commandBus, IJobService jobService, IContentRepository contentRepository) + : base(commandBus) + { + this.commandBus = commandBus; + this.jobService = jobService; + this.contentRepository = contentRepository; + } + + /// + /// Gets the schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// Schema indexes returned. + /// Schema or app not found. + [HttpGet] + [Route("apps/{app}/schemas/{schema}/indexes/")] + [ProducesResponseType(typeof(IndexesDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task GetIndexes(string app, string schema) + { + var indexes = await contentRepository.GetIndexesAsync(App.Id, Schema.Id, HttpContext.RequestAborted); + + var response = Deferred.Response(() => + { + return IndexesDto.FromDomain(indexes, Resources); + }); + + return Ok(response); + } + + /// + /// Create a schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// The request object that represents an index. + /// Schema findexes returned. + /// Schema or app not found. + [HttpPost] + [Route("apps/{app}/schemas/{schema}/indexes/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task PostIndex(string app, string schema, [FromBody] CreateIndexDto request) + { + var job = CreateIndexJob.BuildRequest(User.Token()!, App, Schema, request.ToIndex()); + + await jobService.StartAsync(App.Id, job, HttpContext.RequestAborted); + + return NoContent(); + } + + /// + /// Create a schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// The name of the index. + /// Schema index deletion added to job queue. + /// Schema or app not found. + [HttpPost] + [Route("apps/{app}/schemas/{schema}/indexes/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task DeleteIndex(string app, string schema, string name) + { + var job = DropIndexJob.BuildRequest(User.Token()!, App, Schema, name); + + await jobService.StartAsync(App.Id, job, HttpContext.RequestAborted); + + return NoContent(); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index ebe90b0ffe..b34d390d25 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -98,7 +98,7 @@ public IActionResult GetSchema(string app, string schema) /// Schema name already in use. [HttpPost] [Route("apps/{app}/schemas/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasCreate)] [ApiCosts(1)] public async Task PostSchema(string app, [FromBody] CreateSchemaDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs index 5d2426cc81..b8252cd4f5 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs @@ -93,7 +93,7 @@ public IActionResult GetTeam(string team) /// [HttpPost] [Route("teams/")] - [ProducesResponseType(typeof(TeamDto), 201)] + [ProducesResponseType(typeof(TeamDto), StatusCodes.Status201Created)] [ApiPermission] [ApiCosts(0)] public async Task PostTeam([FromBody] CreateTeamDto request) diff --git a/backend/src/Squidex/Config/Messaging/MessagingServices.cs b/backend/src/Squidex/Config/Messaging/MessagingServices.cs index 9e8e2cf5d9..00be684717 100644 --- a/backend/src/Squidex/Config/Messaging/MessagingServices.cs +++ b/backend/src/Squidex/Config/Messaging/MessagingServices.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Indexes; using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Runner; @@ -81,6 +82,12 @@ public static void AddSquidexMessaging(this IServiceCollection services, IConfig services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingleton(c => new SystemTextJsonMessagingSerializer(c.GetRequiredService())); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs new file mode 100644 index 0000000000..f32da6518d --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs @@ -0,0 +1,172 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions.Common; +using Jint.Runtime; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.TestHelpers; +using System.Security.Principal; +using IClock = NodaTime.IClock; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public class CreateIndexJobTests : GivenContext +{ + private readonly IContentRepository contentRepository = A.Fake(); + private readonly CreateIndexJob sut; + + public CreateIndexJobTests() + { + sut = new CreateIndexJob(contentRepository); + } + + [Fact] + public void Should_create_request() + { + var job = + CreateIndexJob.BuildRequest(User, App, Schema, + [ + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending), + ]); + + job.Arguments.Should().BeEquivalentTo( + new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaId() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_field_order_is_invalid() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Invalid", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_fields_are_empty() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_invoke_content_repository() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + IndexDefinition? index = null; + + A.CallTo(() => contentRepository.CreateIndexAsync(App.Id, Schema.Id, A._, CancellationToken)) + .Invokes(x => index = x.GetArgument(2)); + + await sut.RunAsync(context, CancellationToken); + + index.Should().BeEquivalentTo( + [ + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending) + ]); + } + + private JobRunContext CreateContext(Job job) + { + return new JobRunContext(null!, A.Fake(), default) { Actor = User, Job = job, OwnerId = App.Id }; + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs new file mode 100644 index 0000000000..8abe7f9dd5 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs @@ -0,0 +1,132 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions.Common; +using Jint.Runtime; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; +using IClock = NodaTime.IClock; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public class DropIndexJobTests : GivenContext +{ + private readonly IContentRepository contentRepository = A.Fake(); + private readonly DropIndexJob sut; + + public DropIndexJobTests() + { + sut = new DropIndexJob(contentRepository); + } + + [Fact] + public void Should_create_request() + { + var job = DropIndexJob.BuildRequest(User, App, Schema, "MyIndex"); + + job.Arguments.Should().BeEquivalentTo( + new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaId() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_index_name() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_invoke_content_repository() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await sut.RunAsync(context, CancellationToken); + + A.CallTo(() => contentRepository.DropIndexAsync(App.Id, Schema.Id, "MyIndex", CancellationToken)) + .MustHaveHappened(); + } + + private JobRunContext CreateContext(Job job) + { + return new JobRunContext(null!, A.Fake(), default) { Actor = User, Job = job, OwnerId = App.Id }; + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs new file mode 100644 index 0000000000..46db9eddfe --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; + +namespace Squidex.Domain.Apps.Entities.Contents.MongoDb; + +public class AdaptionTests +{ + static AdaptionTests() + { + MongoContentEntity.RegisterClassMap(); + } + + [Fact] + public void Should_adapt_to_meta_field() + { + var source = "lastModified"; + + var result = Adapt.MapPath(source).ToString(); + + Assert.Equal("mt", result); + } + + [Fact] + public void Should_adapt_to_data_field() + { + var source = "data.test"; + + var result = Adapt.MapPath(source).ToString(); + + Assert.Equal("do.test", result); + } + + [Fact] + public void Should_adapt_from_meta_field() + { + var source = "mt"; + + var result = Adapt.MapPathReverse(source).ToString(); + + Assert.Equal("lastModified", result); + } + + [Fact] + public void Should_adapt_from_data_field() + { + var source = "do.test"; + + var result = Adapt.MapPathReverse(source).ToString(); + + Assert.Equal("data.test", result); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs new file mode 100644 index 0000000000..b642ab2a2c --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs @@ -0,0 +1,124 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using MongoDB.Bson; +using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Contents.MongoDb; + +public class IndexParserTests +{ + private readonly BsonDocument validSource = + new BsonDocument + { + ["name"] = "custom_index", + ["key"] = new BsonDocument + { + ["mt"] = 1, + ["mb"] = -1, + ["do.field1"] = 1, + } + }; + + static IndexParserTests() + { + MongoContentEntity.RegisterClassMap(); + } + + [Fact] + public void Should_parse_index() + { + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.True(result); + + definition.Should().BeEquivalentTo( + new IndexDefinition() + { + new IndexField("lastModified", SortOrder.Ascending), + new IndexField("lastModifiedBy", SortOrder.Descending), + new IndexField("data.field1", SortOrder.Ascending), + }); + } + + [Fact] + public void Should_not_parse_index_if_prefix_does_not_match() + { + var result = IndexParser.TryParse(validSource, "prefix_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_name_not_found() + { + validSource.Remove("name"); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_name_has_invalid_type() + { + validSource["name"] = 42; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_not_found() + { + validSource.Remove("key"); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_has_invalid_type() + { + validSource["key"] = 42; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_is_empty() + { + validSource["key"] = new BsonDocument(); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_property_has_invalid_type() + { + validSource["key"].AsBsonDocument["mt"] = "invalid"; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs new file mode 100644 index 0000000000..b7219e8852 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Queries; + +namespace Squidex.Infrastructure.States; + +public class IndexDefinitionTests +{ + [Fact] + public void Should_create_name_for_empty_definition() + { + var definition = new IndexDefinition(); + + Assert.Equal(string.Empty, definition.ToName()); + } + + [Fact] + public void Should_create_name_for_asc_order() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Ascending) + }; + + Assert.Equal("field1_asc", definition.ToName()); + } + + [Fact] + public void Should_create_name_for_dasc_order() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Descending) + }; + + Assert.Equal("field1_desc", definition.ToName()); + } + + [Fact] + public void Should_create_name_for_multiple_fields() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending) + }; + + Assert.Equal("field1_asc_field2_desc", definition.ToName()); + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs index 2b17116d13..6dad84a88a 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs @@ -78,6 +78,14 @@ public void Should_escape_json() Assert.Equal("Hello \\\"World\\\"", actual); } + [Fact] + public void Should_unescape_json() + { + var actual = StringExtensions.JsonUnescape("Hello \\\"World\\\""); + + Assert.Equal("Hello \"World\"", actual); + } + [Theory] [InlineData("", "")] [InlineData(" ", "")] diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.component.html b/frontend/src/app/framework/angular/forms/editors/toggle.component.html index 650f89f4ad..2d52557953 100644 --- a/frontend/src/app/framework/angular/forms/editors/toggle.component.html +++ b/frontend/src/app/framework/angular/forms/editors/toggle.component.html @@ -6,4 +6,6 @@ [class.unchecked]="snapshot.isChecked === false" (click)="changeState()">
+ + diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.component.scss b/frontend/src/app/framework/angular/forms/editors/toggle.component.scss index b1b7c510d6..c9013c1fae 100644 --- a/frontend/src/app/framework/angular/forms/editors/toggle.component.scss +++ b/frontend/src/app/framework/angular/forms/editors/toggle.component.scss @@ -39,6 +39,10 @@ $toggle-button-size: $toggle-height - .25rem; .toggle-button { left: $toggle-height * .5; } + + .icon-checkmark { + display: block; + } } &.unchecked { @@ -47,6 +51,10 @@ $toggle-button-size: $toggle-height - .25rem; .toggle-button { left: $toggle-width - $toggle-height * .5; } + + .icon-close { + display: block; + } } &.disabled { @@ -55,4 +63,24 @@ $toggle-button-size: $toggle-height - .25rem; cursor: not-allowed; } } +} + +.icon-close { + @include absolute(50%, null, null, 4px); + color: $color-white; + display: none; + font-size: 60%; + font-weight: normal; + margin-top: -5px; + user-select: none; +} + +.icon-checkmark { + @include absolute(50%, 4px); + color: $color-white; + display: none; + font-size: 70%; + font-weight: normal; + margin-top: -5px; + user-select: none; } \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts b/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts new file mode 100644 index 0000000000..b14a209974 --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts @@ -0,0 +1,56 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormsModule } from '@angular/forms'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { RadioGroupComponent, ToggleComponent } from '@app/framework'; + +export default { + title: 'Framework/Toggle', + component: ToggleComponent, + argTypes: { + disabled: { + control: 'boolean', + }, + change: { + action:'ngModelChange', + }, + }, + render: args => ({ + props: args, + template: ` + + + `, + }), + decorators: [ + moduleMetadata({ + imports: [ + FormsModule, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Checked: Story = { + args: { + model: true, + }, +}; + +export const Unchecked: Story = { + args: { + model: false, + }, +}; \ No newline at end of file