Browse Source

Custom indexes. (#1134)

* Custom indexes.

* Adjust test.
pull/1137/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
09f7b21c52
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 62
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs
  2. 30
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  3. 19
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  4. 19
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs
  5. 47
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs
  6. 58
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs
  7. 3
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs
  8. 12
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs
  9. 107
      backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs
  10. 80
      backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs
  11. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  12. 19
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs
  13. 46
      backend/src/Squidex.Infrastructure/States/IndexDefinition.cs
  14. 7
      backend/src/Squidex.Infrastructure/StringExtensions.cs
  15. 1
      backend/src/Squidex.Shared/PermissionIds.cs
  16. 2
      backend/src/Squidex.Web/Resources.cs
  17. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  18. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  19. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  20. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  21. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  22. 34
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs
  23. 51
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs
  24. 32
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs
  25. 44
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs
  26. 6
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs
  27. 4
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  28. 107
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs
  29. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  30. 2
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs
  31. 7
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  32. 172
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs
  33. 132
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs
  34. 59
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs
  35. 124
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs
  36. 55
      backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs
  37. 8
      backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs
  38. 2
      frontend/src/app/framework/angular/forms/editors/toggle.component.html
  39. 28
      frontend/src/app/framework/angular/forms/editors/toggle.component.scss
  40. 56
      frontend/src/app/framework/angular/forms/editors/toggle.stories.ts
  41. 6
      tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt

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

30
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -11,6 +11,7 @@ using NodaTime;
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 sealed class MongoContentCollection : MongoRepositoryBase<MongoContentEnt
add(Collection, entity);
}
public async Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
await queryInDedicatedCollection.CreateIndexAsync(appId, schemaId, index, ct);
}
}
public async Task DropIndexAsync(DomainId appId, DomainId schemaId, string name,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
await queryInDedicatedCollection.DropIndexAsync(appId, schemaId, name, ct);
}
}
public async Task<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
return await queryInDedicatedCollection.GetIndexesAsync(appId, schemaId, ct);
}
return [];
}
}

19
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -20,6 +20,7 @@ using Squidex.Infrastructure;
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 partial class MongoContentRepository : MongoBase<MongoContentEntity>, ICo
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<List<IndexDefinition>> 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;

19
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 sealed class MongoShardedContentRepository : ShardedSnapshotStore<MongoCo
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<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
return Shard(appId).GetIndexesAsync(appId, schemaId, ct);
}
public async IAsyncEnumerable<Content> StreamScheduledWithoutDataAsync(Instant now, SearchScope scope,
[EnumeratorCancellation] CancellationToken ct = default)
{

47
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<string, PropertyPath> pathMap;
private static Dictionary<string, PropertyPath> pathReverseMap;
private static Dictionary<string, string> propertyMap;
private static Dictionary<string, string> propertyReverseMap;
public static IReadOnlyDictionary<string, string> PropertyMap
{
@ -28,13 +31,29 @@ public static class Adapt
StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, string> PropertyReverseMap
{
get => propertyReverseMap ??=
BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).AllMemberMaps
.ToDictionary(
x => x.ElementName,
x => x.MemberName.ToCamelCase(),
StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, PropertyPath> PathMap
{
get => pathMap ??= PropertyMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value);
}
public static IReadOnlyDictionary<string, PropertyPath> 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 class Adapt
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<string>(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)

58
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 @@ internal sealed class QueryInDedicatedCollection : MongoBase<MongoContentEntity>
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<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
var result = new List<IndexDefinition>();
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<MongoContentEntity>(
definition,
new CreateIndexOptions
{
Name = name,
}),
cancellationToken: ct);
}
private static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filter)
{
var filters = new List<FilterDefinition<MongoContentEntity>>

3
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs

@ -50,6 +50,9 @@ public sealed class BackupJob : IJobRunner
public static JobRequest BuildRequest(RefToken actor, App app)
{
Guard.NotNull(actor);
Guard.NotNull(app);
return JobRequest.Create(
actor,
TaskName,

12
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs

@ -79,6 +79,9 @@ public sealed class RestoreJob : IJobRunner
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 sealed class RestoreJob : IJobRunner
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

107
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<string, string>
{
[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<SortOrder>(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);
}
}

80
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<string, string>
{
[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);
}
}

10
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.Contents;
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 @@ public interface IContentRepository
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<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default);
}

19
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 sealed class RuleRunnerJob : IJobRunner
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 sealed class RuleRunnerJob : IJobRunner
{
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 sealed class RuleRunnerJob : IJobRunner
Rule = rule,
};
if (fromSnapshot && ruleService.CanCreateSnapshotEvents(rule))
if (fromSnapshotValue && ruleService.CanCreateSnapshotEvents(rule))
{
await EnqueueFromSnapshotsAsync(ruleContext, ct);
}
@ -166,7 +171,8 @@ public sealed class RuleRunnerJob : IJobRunner
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 @@ public sealed class RuleRunnerJob : IJobRunner
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);
}
}
}

46
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<IndexField>
{
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);

7
backend/src/Squidex.Infrastructure/StringExtensions.cs

@ -30,6 +30,13 @@ public static partial class StringExtensions
return value;
}
public static string JsonUnescape(this string value)
{
value = JsonSerializer.Deserialize<string>($"\"{value}\"", JsonEscapeOptions)!;
return value;
}
public static bool IsEmail(this string? value)
{
return value != null && RegexEmail.IsMatch(value);

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

2
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);

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -67,7 +67,7 @@ public sealed class AppClientsController : ApiController
/// </remarks>
[HttpPost]
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientsDto), 201)]
[ProducesResponseType(typeof(ClientsDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppClientsCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostClient(string app, [FromBody] CreateClientDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -61,7 +61,7 @@ public sealed class AppLanguagesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(AppLanguagesDto), 201)]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppLanguagesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostLanguage(string app, [FromBody] AddLanguageDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -88,7 +88,7 @@ public sealed class AppRolesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/roles/")]
[ProducesResponseType(typeof(RolesDto), 201)]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppRolesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostRole(string app, [FromBody] AddRoleDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -128,7 +128,7 @@ public sealed class AppsController : ApiController
/// </remarks>
[HttpPost]
[Route("apps/")]
[ProducesResponseType(typeof(AppDto), 201)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status201Created)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> PostApp([FromBody] CreateAppDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -112,7 +112,7 @@ public sealed class RulesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RuleDto), 201)]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppRulesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostRule(string app, [FromBody] CreateRuleDto request)

34
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
{
/// <summary>
/// The index fields.
/// </summary>
[LocalizedRequired]
public List<IndexFieldDto> 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;
}
}

51
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
{
/// <summary>
/// The name of the index.
/// </summary>
[LocalizedRequired]
public string Name { get; set; }
/// <summary>
/// The index fields.
/// </summary>
[LocalizedRequired]
public List<IndexFieldDto> 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<SchemaIndexesController>(x => nameof(x.DeleteIndex), values));
}
return this;
}
}

32
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
{
/// <summary>
/// The name of the field.
/// </summary>
[LocalizedRequired]
public string Name { get; set; }
/// <summary>
/// The sort order of the field.
/// </summary>
public SortOrder Order { get; set; }
public static IndexFieldDto FromDomain(IndexField field)
{
return SimpleMapper.Map(field, new IndexFieldDto());
}
}

44
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
{
/// <summary>
/// The indexes.
/// </summary>
public IndexDto[] Items { get; set; }
public static IndexesDto FromDomain(List<IndexDefinition> 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<SchemaIndexesController>(x => nameof(x.GetIndexes), values));
if (resources.CanManageIndexes(resources.Schema!))
{
AddPostLink("create",
resources.Url<SchemaIndexesController>(x => nameof(x.PostIndex), values));
}
return this;
}
}

6
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs

@ -159,6 +159,12 @@ public class SchemaDto : Resource
resources.Url<ContentsController>(x => nameof(x.GetContents), values));
}
if (resources.CanManageIndexes(Name) && Type == SchemaType.Default)
{
AddGetLink("indexes",
resources.Url<SchemaIndexesController>(x => nameof(x.GetIndexes), values));
}
if (resources.CanCreateContent(Name) && Type == SchemaType.Default)
{
AddPostLink("contents/create",

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

@ -38,7 +38,7 @@ public sealed class SchemaFieldsController : ApiController
/// <response code="409">Schema field name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/fields/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PostField(string app, string schema, [FromBody] AddFieldDto request)
@ -63,7 +63,7 @@ public sealed class SchemaFieldsController : ApiController
/// <response code="404">Schema, field or app not found.</response>
[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<IActionResult> PostNestedField(string app, string schema, long parentId, [FromBody] AddFieldDto request)

107
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;
/// <summary>
/// Update and query information about schemas.
/// </summary>
[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;
}
/// <summary>
/// Gets the schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <response code="200">Schema indexes returned.</response>
/// <response code="404">Schema or app not found.</response>
[HttpGet]
[Route("apps/{app}/schemas/{schema}/indexes/")]
[ProducesResponseType(typeof(IndexesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Create a schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The request object that represents an index.</param>
/// <response code="200">Schema findexes returned.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/indexes/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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();
}
/// <summary>
/// Create a schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="name">The name of the index.</param>
/// <response code="204">Schema index deletion added to job queue.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/indexes/{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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();
}
}

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

@ -98,7 +98,7 @@ public sealed class SchemasController : ApiController
/// <response code="409">Schema name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs

@ -93,7 +93,7 @@ public sealed class TeamsController : ApiController
/// </remarks>
[HttpPost]
[Route("teams/")]
[ProducesResponseType(typeof(TeamDto), 201)]
[ProducesResponseType(typeof(TeamDto), StatusCodes.Status201Created)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> PostTeam([FromBody] CreateTeamDto request)

7
backend/src/Squidex/Config/Messaging/MessagingServices.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Entities.Assets;
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 class MessagingServices
services.AddSingletonAs<RuleRunnerJob>()
.As<IJobRunner>();
services.AddSingletonAs<CreateIndexJob>()
.As<IJobRunner>();
services.AddSingletonAs<DropIndexJob>()
.As<IJobRunner>();
services.AddSingleton<IMessagingSerializer>(c =>
new SystemTextJsonMessagingSerializer(c.GetRequiredService<JsonSerializerOptions>()));

172
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<IContentRepository>();
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<string, string>
{
["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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaName"] = Schema.Name,
["field_field1"] = "Ascending",
["field_field2"] = "Descending"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_field_order_is_invalid()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_fields_are_empty()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["schemaName"] = Schema.Name,
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_invoke_content_repository()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<IndexDefinition>._, CancellationToken))
.Invokes(x => index = x.GetArgument<IndexDefinition>(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<IClock>(), default) { Actor = User, Job = job, OwnerId = App.Id };
}
}

132
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<IContentRepository>();
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<string, string>
{
["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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaName"] = Schema.Name,
["indexName"] = "MyIndex"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["indexName"] = "MyIndex"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => 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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["schemaName"] = Schema.Name,
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_invoke_content_repository()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<IClock>(), default) { Actor = User, Job = job, OwnerId = App.Id };
}
}

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

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

55
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());
}
}

8
backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs

@ -78,6 +78,14 @@ public class StringExtensionsTests
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(" ", "")]

2
frontend/src/app/framework/angular/forms/editors/toggle.component.html

@ -6,4 +6,6 @@
[class.unchecked]="snapshot.isChecked === false"
(click)="changeState()">
<div class="toggle-button"></div>
<i class="icon-close"></i>
<i class="icon-checkmark"></i>
</div>

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

56
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: `
<sqx-toggle
[disabled]="disabled"
(ngModelChange)="change($event)"
[ngModel]="model">
</sqx-toggle>
`,
}),
decorators: [
moduleMetadata({
imports: [
FormsModule,
],
}),
],
} as Meta;
type Story = StoryObj<RadioGroupComponent & { model: any }>;
export const Default: Story = {};
export const Checked: Story = {
args: {
model: true,
},
};
export const Unchecked: Story = {
args: {
model: false,
},
};

6
tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt

@ -161,6 +161,9 @@
fields/ui: {
Method: PUT
},
indexes: {
Method: GET
},
self: {
Method: GET
},
@ -348,6 +351,9 @@
fields/ui: {
Method: PUT
},
indexes: {
Method: GET
},
self: {
Method: GET
},

Loading…
Cancel
Save