Browse Source

Streaming endpoint. (#976)

* Streaming endpoint.

* Added streaming result.

* Few style fixes.

* Fix queries.

* Update dependencies.

* Fix tests
pull/978/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
eb0a72808c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs
  2. 8
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  3. 5
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs
  4. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs
  5. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs
  6. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  9. 7
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  10. 70
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  11. 7
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  13. 17
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs
  14. 8
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs
  17. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  18. 29
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  19. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs
  20. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  22. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  23. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  24. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  25. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs
  26. 22
      backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs
  27. 13
      backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs
  28. 2
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  29. 2
      backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs
  30. 12
      backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs
  31. 45
      backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs
  32. 3
      backend/src/Squidex.Shared/PermissionIds.cs
  33. 65
      backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs
  34. 28
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  35. 2
      backend/src/Squidex/Squidex.csproj
  36. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs
  37. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs
  38. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs
  39. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs
  40. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs
  41. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs
  42. 65
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs
  43. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  44. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs
  45. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs
  46. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs
  47. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs
  48. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs
  49. 123
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs
  50. 4
      backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs
  51. 2
      frontend/src/app/features/content/pages/content/editor/content-field.component.html
  52. 2
      frontend/src/app/features/content/pages/content/editor/field-languages.component.html
  53. 4
      frontend/src/app/features/settings/pages/workflows/workflow.component.html
  54. 5
      frontend/src/app/framework/angular/pager.component.html
  55. 23
      frontend/src/app/theme/_bootstrap.scss
  56. 6
      frontend/src/app/theme/_common.scss
  57. 4
      frontend/src/app/theme/_mixins.scss
  58. 29
      tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  59. 2
      tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  60. 4
      tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

12
backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs

@ -9,6 +9,7 @@ using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries;
@ -17,13 +18,12 @@ namespace Squidex.Extensions.Validation;
internal sealed class CompositeUniqueValidator : IValidator
{
private readonly string tag;
private readonly string contentTag;
private readonly IContentRepository contentRepository;
public CompositeUniqueValidator(string tag, IContentRepository contentRepository)
public CompositeUniqueValidator(string contentTag, IContentRepository contentRepository)
{
this.tag = tag;
this.contentTag = contentTag;
this.contentRepository = contentRepository;
}
@ -55,7 +55,7 @@ internal sealed class CompositeUniqueValidator : IValidator
{
var filter = ClrFilter.And(filters);
var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter);
var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, SearchScope.All);
if (found.Any(x => x.Id != context.Root.ContentId))
{
@ -106,7 +106,7 @@ internal sealed class CompositeUniqueValidator : IValidator
{
return
field.Partitioning == Partitioning.Invariant &&
field.RawProperties.Tags?.Contains(tag) == true &&
field.RawProperties.Tags?.Contains(contentTag) == true &&
field.RawProperties is BooleanFieldProperties or DateTimeFieldProperties or NumberFieldProperties or ReferencesFieldProperties or StringFieldProperties;
}
}

8
backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs

@ -31,10 +31,10 @@ public sealed record Role(string Name, PermissionSet? Permissions = null, JsonOb
PermissionIds.AppUsage
};
public const string Editor = "Editor";
public const string Developer = "Developer";
public const string Owner = "Owner";
public const string Reader = "Reader";
public const string Editor = nameof(Editor);
public const string Developer = nameof(Developer);
public const string Owner = nameof(Owner);
public const string Reader = nameof(Reader);
public string Name { get; } = Guard.NotNullOrEmpty(Name);

5
backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs

@ -61,12 +61,11 @@ public static class GeoJsonValue
{
using (var stream = DefaultPools.MemoryStream.GetStream())
{
serializer.Serialize(obj, stream, true);
serializer.Serialize(obj, stream);
stream.Position = 0;
geoJSON = serializer.Deserialize<Geometry>(stream, null, true);
geoJSON = serializer.Deserialize<Geometry>(stream, null);
return true;
}
}

10
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs

@ -15,7 +15,8 @@ using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators;
public delegate Task<IReadOnlyList<IAssetInfo>> CheckAssets(IEnumerable<DomainId> ids);
public delegate Task<IReadOnlyList<IAssetInfo>> CheckAssets(IEnumerable<DomainId> ids,
CancellationToken ct);
public sealed class AssetsValidator : IValidator
{
@ -46,16 +47,17 @@ public sealed class AssetsValidator : IValidator
public void Validate(object? value, ValidationContext context)
{
context.Root.AddTask(ct => ValidateCoreAsync(value, context));
context.Root.AddTask(ct => ValidateCoreAsync(value, context, ct));
}
private async Task ValidateCoreAsync(object? value, ValidationContext context)
private async Task ValidateCoreAsync(object? value, ValidationContext context,
CancellationToken ct)
{
var foundIds = new List<DomainId>();
if (value is ICollection<DomainId> { Count: > 0 } assetIds)
{
var assets = await checkAssets(assetIds);
var assets = await checkAssets(assetIds, ct);
var index = 1;
foreach (var assetId in assetIds)

10
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs

@ -14,7 +14,8 @@ using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators;
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckContentsByIds(HashSet<DomainId> ids);
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckContentsByIds(HashSet<DomainId> ids,
CancellationToken ct);
public sealed class ReferencesValidator : IValidator
{
@ -45,16 +46,17 @@ public sealed class ReferencesValidator : IValidator
public void Validate(object? value, ValidationContext context)
{
context.Root.AddTask(ct => ValidateCoreAsync(value, context));
context.Root.AddTask(ct => ValidateCoreAsync(value, context, ct));
}
private async Task ValidateCoreAsync(object? value, ValidationContext context)
private async Task ValidateCoreAsync(object? value, ValidationContext context,
CancellationToken ct)
{
var foundIds = new List<DomainId>();
if (value is ICollection<DomainId> { Count: > 0 } contentIds)
{
var references = await checkReferences(contentIds.ToHashSet());
var references = await checkReferences(contentIds.ToHashSet(), ct);
var referenceIndex = 1;
foreach (var id in contentIds)

10
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs

@ -13,7 +13,8 @@ using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Core.ValidateContent.Validators;
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckUniqueness(FilterNode<ClrValue> filter);
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckUniqueness(FilterNode<ClrValue> filter,
CancellationToken ct);
public sealed class UniqueValidator : IValidator
{
@ -43,14 +44,15 @@ public sealed class UniqueValidator : IValidator
if (filter != null)
{
context.Root.AddTask(ct => ValidateCoreAsync(context, filter));
context.Root.AddTask(ct => ValidateCoreAsync(context, filter, ct));
}
}
}
private async Task ValidateCoreAsync(ValidationContext context, FilterNode<ClrValue> filter)
private async Task ValidateCoreAsync(ValidationContext context, FilterNode<ClrValue> filter,
CancellationToken ct)
{
var found = await checkUniqueness(filter);
var found = await checkUniqueness(filter, ct);
if (found.Any(x => x.Id != context.Root.ContentId))
{

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs

@ -90,7 +90,7 @@ public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase<Mon
var filters = new List<FilterDefinition<MongoAssetFolderEntity>>
{
Filter.Eq(x => x.IndexedAppId, appId),
Filter.Eq(x => x.IsDeleted, false)
Filter.Ne(x => x.IsDeleted, true)
};
if (parentId != null)

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs

@ -49,7 +49,7 @@ public static class FindExtensions
if (!query.HasFilterField("IsDeleted"))
{
filters.Add(Filter.Eq(x => x.IsDeleted, false));
filters.Add(Filter.Ne(x => x.IsDeleted, true));
isDefault = true;
}

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

@ -100,7 +100,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase<MongoContentEnt
public Task ResetScheduledAsync(DomainId documentId,
CancellationToken ct)
{
return Collection.UpdateOneAsync(x => x.DocumentId == documentId, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt), cancellationToken: ct);
return Collection.UpdateOneAsync(
x => x.DocumentId == documentId,
Update
.Unset(x => x.ScheduleJob)
.Unset(x => x.ScheduledAt),
cancellationToken: ct);
}
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,

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

@ -69,98 +69,68 @@ public partial class MongoContentRepository : MongoBase<MongoContentEntity>, ICo
CanUseTransactions = clusteredAsReplica && clusterVersion >= 4 && options.UseTransactions;
}
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, SearchScope scope,
CancellationToken ct = default)
{
return collectionComplete.StreamAll(appId, schemaIds, ct);
return GetCollection(scope).StreamAll(appId, schemaIds, ct);
}
public IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId reference, int take,
public IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId reference, int take, SearchScope scope,
CancellationToken ct = default)
{
return collectionComplete.StreamReferencing(appId, reference, take, ct);
return GetCollection(scope).StreamReferencing(appId, reference, take, ct);
}
public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
public IAsyncEnumerable<IContentEntity> StreamScheduledWithoutDataAsync(Instant now, SearchScope scope,
CancellationToken ct = default)
{
return collectionComplete.QueryScheduledWithoutDataAsync(now, ct);
return GetCollection(scope).QueryScheduledWithoutDataAsync(now, ct);
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope,
CancellationToken ct = default)
{
if (scope == SearchScope.All)
{
return collectionComplete.QueryAsync(app, schemas, q, ct);
}
else
{
return collectionPublished.QueryAsync(app, schemas, q, ct);
}
return GetCollection(scope).QueryAsync(app, schemas, q, ct);
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope,
CancellationToken ct = default)
{
if (scope == SearchScope.All)
{
return collectionComplete.QueryAsync(app, schema, q, ct);
}
else
{
return collectionPublished.QueryAsync(app, schema, q, ct);
}
return GetCollection(scope).QueryAsync(app, schema, q, ct);
}
public Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope,
CancellationToken ct = default)
{
if (scope == SearchScope.All)
{
return collectionComplete.FindContentAsync(schema, id, ct);
}
else
{
return collectionPublished.FindContentAsync(schema, id, ct);
}
return GetCollection(scope).FindContentAsync(schema, id, ct);
}
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope,
CancellationToken ct = default)
{
if (scope == SearchScope.All)
{
return collectionComplete.QueryIdsAsync(appId, ids, ct);
}
else
{
return collectionPublished.QueryIdsAsync(appId, ids, ct);
}
return GetCollection(scope).QueryIdsAsync(appId, ids, ct);
}
public Task<bool> HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope,
CancellationToken ct = default)
{
if (scope == SearchScope.All)
{
return collectionComplete.HasReferrersAsync(appId, reference, ct);
}
else
{
return collectionPublished.HasReferrersAsync(appId, reference, ct);
}
return GetCollection(scope).HasReferrersAsync(appId, reference, ct);
}
public Task ResetScheduledAsync(DomainId documentId,
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode, SearchScope scope,
CancellationToken ct = default)
{
return collectionComplete.ResetScheduledAsync(documentId, ct);
return GetCollection(scope).QueryIdsAsync(appId, schemaId, filterNode, ct);
}
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
public Task ResetScheduledAsync(DomainId documentId, SearchScope scope,
CancellationToken ct = default)
{
return collectionComplete.QueryIdsAsync(appId, schemaId, filterNode, ct);
return GetCollection(SearchScope.All).ResetScheduledAsync(documentId, ct);
}
private MongoContentCollection GetCollection(SearchScope scope)
{
return scope == SearchScope.All ? collectionComplete : collectionPublished;
}
}

7
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs

@ -44,6 +44,13 @@ public sealed class QueryAsStream : OperationBase
{
filters.Add(Filter.In(x => x.IndexedSchemaId, schemaIds));
}
else
{
// If we also add this filter, it is more likely that the index will be used.
filters.Add(Filter.Exists(x => x.IndexedSchemaId));
}
filters.Add(Filter.Ne(x => x.IsDeleted, true));
return Filter.And(filters);
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs

@ -221,7 +221,7 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
break;
default:
scheduler.Run(callback,ErrorNoAsset);
scheduler.Run(callback, ErrorNoAsset);
break;
}
});

17
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs

@ -22,6 +22,12 @@ public sealed class EnrichForCaching : IAssetEnricherStep
public Task EnrichAsync(Context context,
CancellationToken ct)
{
// Sometimes we just want to skip this for performance reasons.
if (!ShouldEnrich(context))
{
return Task.CompletedTask;
}
context.AddCacheHeaders(requestCache);
return Task.CompletedTask;
@ -30,6 +36,12 @@ public sealed class EnrichForCaching : IAssetEnricherStep
public Task EnrichAsync(Context context, IEnumerable<AssetEntity> assets,
CancellationToken ct)
{
// Sometimes we just want to skip this for performance reasons.
if (!ShouldEnrich(context))
{
return Task.CompletedTask;
}
requestCache.AddDependency(context.App.Id, context.App.Version);
foreach (var asset in assets)
@ -39,4 +51,9 @@ public sealed class EnrichForCaching : IAssetEnricherStep
return Task.CompletedTask;
}
private static bool ShouldEnrich(Context context)
{
return !context.ShouldSkipCacheKeys();
}
}

8
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
@ -96,6 +97,11 @@ public sealed class ScriptAsset : IAssetEnricherStep
private static bool ShouldEnrich(Context context)
{
return !context.IsFrontendClient;
// We need a special permission to disable scripting for security reasons, if the script removes sensible data.
var shouldScript =
!context.ShouldSkipScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
return !context.IsFrontendClient && shouldScript;
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -61,7 +61,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() :
null;
await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, ct))
await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, SearchScope.All, ct))
{
var result = new EnrichedContentEvent
{
@ -105,7 +105,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri
var take = context.MaxEvents.Value;
await foreach (var content in contentRepository.StreamReferencing(context.AppId.Id, enrichedEvent.Id, take, ct))
await foreach (var content in contentRepository.StreamReferencing(context.AppId.Id, enrichedEvent.Id, take, SearchScope.All, ct))
{
var result = new EnrichedContentEvent
{

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs

@ -54,7 +54,7 @@ public sealed class ContentSchedulerProcess : IBackgroundProcess
{
var now = Clock.GetCurrentInstant();
await foreach (var content in contentRepository.QueryScheduledWithoutDataAsync(now, ct))
await foreach (var content in contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, ct))
{
await TryPublishAsync(content);
}

3
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -12,6 +12,9 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public interface IContentQueryService
{
IAsyncEnumerable<IEnrichedContentEntity> StreamAsync(Context context, string schemaIdOrName, int skip,
CancellationToken ct = default);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, Q q,
CancellationToken ct = default);

29
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
@ -41,6 +42,29 @@ public sealed class ContentQueryService : IContentQueryService
this.queryParser = queryParser;
}
public async IAsyncEnumerable<IEnrichedContentEntity> StreamAsync(Context context, string schemaIdOrName, int skip,
[EnumeratorCancellation] CancellationToken ct = default)
{
Guard.NotNull(context);
// We assume that the user has the full read permissions for this schema to optimize the DB query.
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName, ct);
// Skip all expensive operations when we call the enricher.
context = context.Clone(b => b
.WithoutScripting()
.WithoutCacheKeys()
.WithoutContentEnrichment());
// We run this query without a timeout because it is meant for long running background operations.
var contents = contentRepository.StreamAll(context.App.Id, HashSet.Of(schema.Id), context.Scope(), ct);
await foreach (var content in contents.WithCancellation(ct))
{
yield return await contentEnricher.EnrichAsync(content, false, context, ct);
}
}
public async Task<IEnrichedContentEntity?> FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default)
{
@ -55,6 +79,7 @@ public sealed class ContentQueryService : IContentQueryService
IContentEntity? content;
// A special ID to always query the single content of the singleton.
if (id.ToString().Equals(SingletonId, StringComparison.Ordinal))
{
id = schema.Id;
@ -87,6 +112,7 @@ public sealed class ContentQueryService : IContentQueryService
{
activity?.SetTag("schemaName", schemaIdOrName);
// Usually the query should not be null, but we never know.
if (q == null)
{
return ResultList.Empty<IEnrichedContentEntity>();
@ -94,6 +120,7 @@ public sealed class ContentQueryService : IContentQueryService
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName, ct);
// The API only checks for read.own permission, so we might need an additional filter here.
if (!HasPermission(context, schema, PermissionIds.AppContentsRead))
{
q = q with { CreatedBy = context.UserPrincipal.Token() };
@ -119,6 +146,7 @@ public sealed class ContentQueryService : IContentQueryService
using (Telemetry.Activities.StartActivity("ContentQueryService/QueryAsync"))
{
// Usually the query should not be null, but we never know.
if (q == null)
{
return ResultList.Empty<IEnrichedContentEntity>();
@ -126,6 +154,7 @@ public sealed class ContentQueryService : IContentQueryService
var schemas = await GetSchemasAsync(context, ct);
// If the user does not have a permission to query a single schema the database would return an empty result anyway.
if (schemas.Count == 0)
{
return ResultList.Empty<IEnrichedContentEntity>();

18
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs

@ -21,6 +21,12 @@ public sealed class EnrichForCaching : IContentEnricherStep
public Task EnrichAsync(Context context,
CancellationToken ct)
{
// Sometimes we just want to skip this for performance reasons.
if (!ShouldEnrich(context))
{
return Task.CompletedTask;
}
context.AddCacheHeaders(requestCache);
return Task.CompletedTask;
@ -29,8 +35,15 @@ public sealed class EnrichForCaching : IContentEnricherStep
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas,
CancellationToken ct)
{
// Sometimes we just want to skip this for performance reasons.
if (!ShouldEnrich(context))
{
return;
}
var app = context.App;
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
ct.ThrowIfCancellationRequested();
@ -45,4 +58,9 @@ public sealed class EnrichForCaching : IContentEnricherStep
}
}
}
private static bool ShouldEnrich(Context context)
{
return !context.ShouldSkipCacheKeys();
}
}

1
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs

@ -14,6 +14,7 @@ public sealed class EnrichWithSchema : IContentEnricherStep
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas,
CancellationToken ct)
{
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
ct.ThrowIfCancellationRequested();

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -43,6 +43,7 @@ public sealed class ResolveAssets : IContentEnricherStep
var ids = new HashSet<DomainId>();
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var (schema, components) = await schemas(group.Key);
@ -52,6 +53,7 @@ public sealed class ResolveAssets : IContentEnricherStep
var assets = await GetAssetsAsync(context, ids, ct);
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var (schema, components) = await schemas(group.Key);

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -30,7 +30,6 @@ public sealed class ResolveReferences : IContentEnricherStep
public ResolveReferences(Lazy<IContentQueryService> contentQuery, IRequestCache requestCache)
{
this.contentQuery = contentQuery;
this.requestCache = requestCache;
}
@ -44,6 +43,7 @@ public sealed class ResolveReferences : IContentEnricherStep
var ids = new HashSet<DomainId>();
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var (schema, components) = await schemas(group.Key);
@ -53,6 +53,7 @@ public sealed class ResolveReferences : IContentEnricherStep
var references = await GetReferencesAsync(context, ids, ct);
// Group by schema, so we only fetch the schema once.
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var (schema, components) = await schemas(group.Key);

9
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
@ -21,6 +22,7 @@ public sealed class ScriptContent : IContentEnricherStep
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas,
CancellationToken ct)
{
// Sometimes we just want to skip this for performance reasons.
if (!ShouldEnrich(context))
{
return;
@ -93,6 +95,11 @@ public sealed class ScriptContent : IContentEnricherStep
private static bool ShouldEnrich(Context context)
{
return !context.IsFrontendClient;
// We need a special permission to disable scripting for security reasons, if the script removes sensible data.
var shouldScript =
!context.ShouldSkipScripting() ||
!context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name));
return !context.IsFrontendClient && shouldScript;
}
}

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

@ -16,10 +16,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories;
public interface IContentRepository
{
IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
IAsyncEnumerable<IContentEntity> StreamScheduledWithoutDataAsync(Instant now, SearchScope scope,
CancellationToken ct = default);
IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId references, int take,
IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, SearchScope scope,
CancellationToken ct = default);
IAsyncEnumerable<IContentEntity> StreamReferencing(DomainId appId, DomainId references, int take, SearchScope scope,
CancellationToken ct = default);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope,
@ -28,7 +31,7 @@ public interface IContentRepository
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope,
CancellationToken ct = default);
Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode, SearchScope scope,
CancellationToken ct = default);
Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope,
@ -40,9 +43,6 @@ public interface IContentRepository
Task<bool> HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope,
CancellationToken ct = default);
Task ResetScheduledAsync(DomainId documentId,
CancellationToken ct = default);
IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
Task ResetScheduledAsync(DomainId documentId, SearchScope scope,
CancellationToken ct = default);
}

16
backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs

@ -35,9 +35,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory
if (field is IField<AssetsFieldProperties> assetsField)
{
var checkAssets = new CheckAssets(async ids =>
var checkAssets = new CheckAssets(async (ids, ct) =>
{
return await assetRepository.QueryAsync(context.Root.AppId.Id, null, Q.Empty.WithIds(ids), default);
return await assetRepository.QueryAsync(context.Root.AppId.Id, null, Q.Empty.WithIds(ids), ct);
});
yield return new AssetsValidator(isRequired, assetsField.Properties, checkAssets);
@ -45,9 +45,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory
if (field is IField<ReferencesFieldProperties> referencesField)
{
var checkReferences = new CheckContentsByIds(async ids =>
var checkReferences = new CheckContentsByIds(async (ids, ct) =>
{
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, ids, SearchScope.All, default);
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, ids, SearchScope.All, ct);
});
yield return new ReferencesValidator(isRequired, referencesField.Properties, checkReferences);
@ -55,9 +55,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory
if (field is IField<NumberFieldProperties> numberField && numberField.Properties.IsUnique)
{
var checkUniqueness = new CheckUniqueness(async filter =>
var checkUniqueness = new CheckUniqueness(async (f, ct) =>
{
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, default);
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, f, SearchScope.All, ct);
});
yield return new UniqueValidator(checkUniqueness);
@ -65,9 +65,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory
if (field is IField<StringFieldProperties> stringField && stringField.Properties.IsUnique)
{
var checkUniqueness = new CheckUniqueness(async filter =>
var checkUniqueness = new CheckUniqueness(async (f, ct) =>
{
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, default);
return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, f, SearchScope.All, ct);
});
yield return new UniqueValidator(checkUniqueness);

22
backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs

@ -11,6 +11,28 @@ public static class ContextExtensions
{
private const string HeaderNoTotal = "X-NoTotal";
private const string HeaderNoSlowTotal = "X-NoSlowTotal";
private const string HeaderNoCacheKeys = "X-NoCacheKeys";
private const string HeaderNoScripting = "X-NoScripting";
public static bool ShouldSkipCacheKeys(this Context context)
{
return context.Headers.ContainsKey(HeaderNoCacheKeys);
}
public static ICloneBuilder WithoutCacheKeys(this ICloneBuilder builder, bool value = true)
{
return builder.WithBoolean(HeaderNoCacheKeys, value);
}
public static bool ShouldSkipScripting(this Context context)
{
return context.Headers.ContainsKey(HeaderNoScripting);
}
public static ICloneBuilder WithoutScripting(this ICloneBuilder builder, bool value = true)
{
return builder.WithBoolean(HeaderNoScripting, value);
}
public static bool ShouldSkipTotal(this Context context)
{

13
backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs

@ -49,22 +49,27 @@ public sealed class RuleEnricher : IRuleEnricher
results.Add(result);
}
// Sometimes we just want to skip this for performance reasons.
var enrichCacheKeys = !context.ShouldSkipCacheKeys();
foreach (var group in results.GroupBy(x => x.AppId.Id))
{
var statistics = await ruleUsageTracker.GetTotalByAppAsync(group.Key, ct);
foreach (var rule in group)
{
requestCache.AddDependency(rule.UniqueId, rule.Version);
if (statistics.TryGetValue(rule.Id, out var statistic))
{
rule.NumFailed = statistic.TotalFailed;
rule.NumSucceeded = statistic.TotalSucceeded;
}
requestCache.AddDependency(rule.NumFailed);
requestCache.AddDependency(rule.NumSucceeded);
if (enrichCacheKeys)
{
requestCache.AddDependency(rule.UniqueId, rule.Version);
requestCache.AddDependency(rule.NumFailed);
requestCache.AddDependency(rule.NumSucceeded);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -29,7 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Notifo.SDK" Version="1.5.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.CLI.Core" Version="9.5.0" />
<PackageReference Include="Squidex.CLI.Core" Version="9.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="7.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

2
backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs

@ -76,7 +76,7 @@ public sealed class DefaultDomainObjectCache : IDomainObjectCache
{
using (var stream = DefaultPools.MemoryStream.GetStream())
{
serializer.Serialize(snapshot, stream, true);
serializer.Serialize(snapshot, stream);
await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct);
}

12
backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs

@ -13,11 +13,17 @@ public interface IJsonSerializer
string Serialize(object? value, Type type, bool indented = false);
void Serialize<T>(T value, Stream stream, bool leaveOpen = false);
void Serialize<T>(T value, Stream stream, bool indented = false);
void Serialize(object? value, Type type, Stream stream, bool leaveOpen = false);
void Serialize(object? value, Type type, Stream stream, bool indented = false);
Task SerializeAsync<T>(T value, Stream stream, bool indented = false,
CancellationToken ct = default);
Task SerializeAsync(object? value, Type type, Stream stream, bool indented = false,
CancellationToken ct = default);
T Deserialize<T>(string value, Type? actualType = null);
T Deserialize<T>(Stream stream, Type? actualType = null, bool leaveOpen = false);
T Deserialize<T>(Stream stream, Type? actualType = null);
}

45
backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs

@ -41,7 +41,7 @@ public sealed class SystemJsonSerializer : IJsonSerializer
}
}
public T Deserialize<T>(Stream stream, Type? actualType = null, bool leaveOpen = false)
public T Deserialize<T>(Stream stream, Type? actualType = null)
{
try
{
@ -52,13 +52,6 @@ public sealed class SystemJsonSerializer : IJsonSerializer
ThrowHelper.JsonException(ex.Message, ex);
return default!;
}
finally
{
if (!leaveOpen)
{
stream.Dispose();
}
}
}
public string Serialize<T>(T value, bool indented = false)
@ -66,11 +59,11 @@ public sealed class SystemJsonSerializer : IJsonSerializer
return Serialize(value, typeof(T), indented);
}
public string Serialize(object? value, Type type, bool intented = false)
public string Serialize(object? value, Type type, bool indented = false)
{
try
{
var options = intented ? optionsIndented : optionsNormal;
var options = indented ? optionsIndented : optionsNormal;
return JsonSerializer.Serialize(value, type, options);
}
@ -81,27 +74,43 @@ public sealed class SystemJsonSerializer : IJsonSerializer
}
}
public void Serialize<T>(T value, Stream stream, bool leaveOpen = false)
public void Serialize<T>(T value, Stream stream, bool indented = false)
{
Serialize(value, typeof(T), stream, leaveOpen);
Serialize(value, typeof(T), stream, indented);
}
public void Serialize(object? value, Type type, Stream stream, bool leaveOpen = false)
public void Serialize(object? value, Type type, Stream stream, bool indented = false)
{
try
{
var options = indented ? optionsIndented : optionsNormal;
JsonSerializer.Serialize(stream, value, optionsNormal);
}
catch (SystemJsonException ex)
{
ThrowHelper.JsonException(ex.Message, ex);
}
finally
}
public Task SerializeAsync<T>(T value, Stream stream, bool indented = false,
CancellationToken ct = default)
{
return SerializeAsync(value, typeof(T), stream, indented, ct);
}
public async Task SerializeAsync(object? value, Type type, Stream stream, bool indented = false,
CancellationToken ct = default)
{
try
{
if (!leaveOpen)
{
stream.Dispose();
}
var options = indented ? optionsIndented : optionsNormal;
await JsonSerializer.SerializeAsync(stream, value, optionsNormal, ct);
}
catch (SystemJsonException ex)
{
ThrowHelper.JsonException(ex.Message, ex);
}
}
}

3
backend/src/Squidex.Shared/PermissionIds.cs

@ -69,6 +69,9 @@ namespace Squidex.Shared
// App
public const string App = "squidex.apps.{app}";
// App
public const string AppNoScripting = "squidex.apps.{app}.no-scripting";
// App General
public const string AppAdmin = "squidex.apps.{app}.*";
public const string AppDelete = "squidex.apps.{app}.delete";

65
backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs

@ -0,0 +1,65 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure.Json;
namespace Squidex.Web.Pipeline;
public sealed class JsonStreamResult<T> : ActionResult
{
#pragma warning disable RECS0108 // Warns about static fields in generic types
public static readonly byte[] Prefix = Encoding.UTF8.GetBytes("data: ");
public static readonly byte[] Separator = Encoding.UTF8.GetBytes("\n\n");
#pragma warning restore RECS0108 // Warns about static fields in generic types
private readonly IAsyncEnumerable<T> stream;
public JsonStreamResult(IAsyncEnumerable<T> stream)
{
this.stream = stream;
}
public override async Task ExecuteResultAsync(ActionContext context)
{
DisableResponseBuffering(context.HttpContext);
var serializer = context.HttpContext.RequestServices.GetRequiredService<IJsonSerializer>();
// The official content type for server sent events.
context.HttpContext.Request.Headers[HeaderNames.ContentType] = "text/event-stream";
context.HttpContext.Request.Headers[HeaderNames.CacheControl] = "no-cache";
var ct = context.HttpContext.RequestAborted;
var body = context.HttpContext.Response.Body;
await foreach (var item in stream.WithCancellation(context.HttpContext.RequestAborted))
{
// Every line needs to start with data.
await body.WriteAsync(Prefix, ct);
await serializer.SerializeAsync(item, body, false, ct);
// Write the separator after a every json object to simplify deserialization.
await body.WriteAsync(Separator, ct);
}
}
private static void DisableResponseBuffering(HttpContext context)
{
var bufferingFeature = context.Features.Get<IHttpResponseBodyFeature>();
if (bufferingFeature != null)
{
bufferingFeature.DisableBuffering();
}
}
}

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

@ -6,6 +6,7 @@
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities;
@ -15,6 +16,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
using Squidex.Web;
using Squidex.Web.Pipeline;
namespace Squidex.Areas.Api.Controllers.Contents;
@ -33,6 +35,30 @@ public sealed class ContentsController : ApiController
this.contentWorkflow = contentWorkflow;
}
/// <summary>
/// Streams contents.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="skip">The number of items to skip.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpGet]
[Route("content/{app}/{schema}/stream")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppContentsRead)]
[ApiCosts(10)]
[OpenApiIgnore]
public IActionResult StreamContents(string app, string schema, [FromQuery] int skip = 0)
{
var contents = contentQuery.StreamAsync(Context, schema, skip, HttpContext.RequestAborted);
return new JsonStreamResult<IEnrichedContentEntity>(contents);
}
/// <summary>
/// Queries contents.
/// </summary>
@ -40,7 +66,7 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="ids">The optional ids of the content to fetch.</param>
/// <param name="q">The optional json query.</param>
/// <response code="200">Contents retunred.</response>.
/// <response code="200">Contents returned.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.

2
backend/src/Squidex/Squidex.csproj

@ -70,7 +70,7 @@
<PackageReference Include="Squidex.Assets.Mongo" Version="5.4.0" />
<PackageReference Include="Squidex.Assets.S3" Version="5.4.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="5.4.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="14.1.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="14.2.0" />
<PackageReference Include="Squidex.Hosting" Version="5.4.0" />
<PackageReference Include="Squidex.Messaging.All" Version="5.4.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="5.4.0" />

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs

@ -27,7 +27,7 @@ public class AssetsFieldTests : IClassFixture<TranslationsFixture>
{
if (field is IField<AssetsFieldProperties> assets)
{
yield return new AssetsValidator(assets.Properties.IsRequired, assets.Properties, ids =>
yield return new AssetsValidator(assets.Properties.IsRequired, assets.Properties, (ids, ct) =>
{
var actual = ids.Select(TestAssets.Document).ToList();

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs

@ -36,7 +36,7 @@ public class ReferencesFieldTests : IClassFixture<TranslationsFixture>
{
if (field is IField<ReferencesFieldProperties> references)
{
yield return new ReferencesValidator(references.Properties.IsRequired, references.Properties, ids =>
yield return new ReferencesValidator(references.Properties.IsRequired, references.Properties, (ids, ct) =>
{
var actual = ids.Select(x => new ContentIdStatus(schemaId, x, Status.Published)).ToList();

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs

@ -257,7 +257,7 @@ public class AssetsValidatorTests : IClassFixture<TranslationsFixture>
private static CheckAssets FoundAssets()
{
return ids =>
return (ids, ct) =>
{
var actual = new List<IAssetInfo> { Document, Image1, Image2, ImageSvg, Video };

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs

@ -215,7 +215,7 @@ public class ReferencesValidatorTests : IClassFixture<TranslationsFixture>
private static CheckContentsByIds FoundReferences(DomainId schemaId, params (DomainId Id, Status Status)[] references)
{
return x =>
return (ids, ct) =>
{
var actual = references.Select(x => new ContentIdStatus(schemaId, x.Id, x.Status)).ToList();

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs

@ -95,7 +95,7 @@ public class UniqueValidatorTests : IClassFixture<TranslationsFixture>
private static CheckUniqueness FoundDuplicates(DomainId id, Action<string>? filter = null)
{
return filterNode =>
return (filterNode, ct) =>
{
filter?.Invoke(filterNode.ToString());

20
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs

@ -59,6 +59,26 @@ public class EnrichForCachingTests : GivenContext
.MustHaveHappened();
}
[Fact]
public async Task Should_not_add_cache_headers_if_disabled()
{
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_add_cache_headers_for_assets_if_disabled()
{
var asset = CreateAsset();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(asset, 1), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened();
}
private AssetEntity CreateAsset()
{
return new AssetEntity { AppId = AppId, Id = DomainId.NewGuid(), Version = 13 };

65
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs

@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Assets.Queries;
@ -26,7 +27,7 @@ public class ScriptAssetTests : GivenContext
[Fact]
public async Task Should_not_call_script_engine_if_no_script_configured()
{
var asset = new AssetEntity();
var asset = CreateAsset();
await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken);
@ -37,10 +38,9 @@ public class ScriptAssetTests : GivenContext
[Fact]
public async Task Should_not_call_script_engine_for_frontend_user()
{
A.CallTo(() => App.AssetScripts)
.Returns(new AssetScripts { Query = "my-query" });
SetupScript(query: "my-query");
var asset = new AssetEntity();
var asset = CreateAsset();
await sut.EnrichAsync(FrontendContext, new[] { asset }, CancellationToken);
@ -48,13 +48,25 @@ public class ScriptAssetTests : GivenContext
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_call_script_engine_if_disabled_and_user_has_permission()
{
SetupScript(query: "my-query");
var asset = CreateAsset();
await sut.EnrichAsync(ContextWithNoScript(), new[] { asset }, CancellationToken);
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_call_script_engine()
{
A.CallTo(() => App.AssetScripts)
.Returns(new AssetScripts { Query = "my-query" });
SetupScript(query: "my-query");
var asset = new AssetEntity { Id = DomainId.NewGuid() };
var asset = CreateAsset();
await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken);
@ -65,17 +77,17 @@ public class ScriptAssetTests : GivenContext
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(), CancellationToken))
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_make_test_with_pre_query_script()
public async Task Should_call_script_engine_with_pre_query_script()
{
A.CallTo(() => App.AssetScripts)
.Returns(new AssetScripts { Query = "my-query", QueryPre = "my-pre-query" });
SetupScript(query: "my-query", queryPre: "my-pre-query");
var asset = new AssetEntity { Id = DomainId.NewGuid() };
var asset = CreateAsset();
await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken);
@ -86,7 +98,8 @@ public class ScriptAssetTests : GivenContext
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-pre-query",
ScriptOptions(), CancellationToken))
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(
@ -96,12 +109,36 @@ public class ScriptAssetTests : GivenContext
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(), CancellationToken))
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
private void SetupScript(string? query = null, string? queryPre = null)
{
A.CallTo(() => App.AssetScripts)
.Returns(new AssetScripts
{
Query = query,
QueryPre = queryPre
});
}
private static AssetEntity CreateAsset()
{
return new AssetEntity { Id = DomainId.NewGuid() };
}
private static ScriptOptions ScriptOptions()
{
return A<ScriptOptions>.That.Matches(x => x.AsContext);
}
private Context ContextWithNoScript()
{
var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id;
var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting());
return contextInstance;
}
}

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -125,7 +125,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext
{
var ctx = Context();
A.CallTo(() => contentRepository.StreamAll(AppId.Id, null, CancellationToken))
A.CallTo(() => contentRepository.StreamAll(AppId.Id, null, SearchScope.All, CancellationToken))
.Returns(new List<ContentEntity>
{
new ContentEntity { SchemaId = schemaMatching },
@ -157,7 +157,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext
var ctx = Context(trigger);
A.CallTo(() => contentRepository.StreamAll(AppId.Id, A<HashSet<DomainId>>.That.Is(schemaMatching.Id), CancellationToken))
A.CallTo(() => contentRepository.StreamAll(AppId.Id, A<HashSet<DomainId>>.That.Is(schemaMatching.Id), SearchScope.All, CancellationToken))
.Returns(new List<ContentEntity>
{
new ContentEntity { SchemaId = schemaMatching },
@ -231,7 +231,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext
SetupData(@event, 12);
A.CallTo(() => contentRepository.StreamReferencing(AppId.Id, @event.ContentId, 100, CancellationToken))
A.CallTo(() => contentRepository.StreamReferencing(AppId.Id, @event.ContentId, 100, SearchScope.All, CancellationToken))
.Returns(new List<ContentEntity>
{
new ContentEntity { SchemaId = schemaMatching },

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs

@ -53,7 +53,7 @@ public class ContentSchedulerProcessTests : GivenContext
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken))
A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken))
.Returns(new[] { content1, content2 }.ToAsyncEnumerable());
await sut.PublishAsync(CancellationToken);
@ -90,7 +90,7 @@ public class ContentSchedulerProcessTests : GivenContext
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken))
A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken))
.Returns(new[] { content1 }.ToAsyncEnumerable());
await sut.PublishAsync(CancellationToken);
@ -114,7 +114,7 @@ public class ContentSchedulerProcessTests : GivenContext
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken))
A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken))
.Returns(new[] { content1 }.ToAsyncEnumerable());
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._, default))
@ -122,7 +122,7 @@ public class ContentSchedulerProcessTests : GivenContext
await sut.PublishAsync(CancellationToken);
A.CallTo(() => contentRepository.ResetScheduledAsync(content1.UniqueId, default))
A.CallTo(() => contentRepository.ResetScheduledAsync(content1.UniqueId, SearchScope.All, default))
.MustHaveHappened();
}
}

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs

@ -104,7 +104,7 @@ public abstract class ContentsQueryFixtureBase : IAsyncLifetime
private async Task CreateDataAsync(
CancellationToken ct)
{
if (await ContentRepository.StreamAll(AppIds[0].Id, null, ct).AnyAsync(ct))
if (await ContentRepository.StreamAll(AppIds[0].Id, null, SearchScope.All, ct).AnyAsync(ct))
{
return;
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs

@ -68,7 +68,7 @@ public abstract class ContentsQueryTestsBase
{
var filter = F.Eq("data.field1.iv", 12);
var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter);
var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter, SearchScope.All);
// We have a concrete query, so we expect an actual.
Assert.NotEmpty(contents);
@ -93,7 +93,7 @@ public abstract class ContentsQueryTestsBase
{
var time = SystemClock.Instance.GetCurrentInstant();
var contents = await _.ContentRepository.QueryScheduledWithoutDataAsync(time).ToListAsync();
var contents = await _.ContentRepository.StreamScheduledWithoutDataAsync(time, SearchScope.All).ToListAsync();
// The IDs are random here, as it does not really matter.
Assert.NotNull(contents);

20
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs

@ -63,6 +63,26 @@ public class EnrichForCachingTests : GivenContext
.MustHaveHappened();
}
[Fact]
public async Task Should_not_add_cache_headers_if_disabled()
{
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_add_cache_headers_for_contents_if_disabled()
{
var content = CreateContent();
await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(content, 1), SchemaProvider(), CancellationToken);
A.CallTo(() => requestCache.AddHeader(A<string>._))
.MustNotHaveHappened();
}
private ContentEntity CreateContent()
{
return new ContentEntity { AppId = AppId, Id = DomainId.NewGuid(), SchemaId = SchemaId, Version = 13 };

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs

@ -286,8 +286,10 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
.AddField("ref2",
new ContentFieldData()
.AddInvariant(JsonValue.Array(ref2.Select(x => x.ToString())))),
SchemaId = SchemaId,
AppId = AppId,
SchemaId = SchemaId,
Status = Status.Draft,
StatusColor = null!,
Version = 0
};
}
@ -305,8 +307,10 @@ public class ResolveReferencesTests : GivenContext, IClassFixture<TranslationsFi
.AddField("number",
new ContentFieldData()
.AddInvariant(number)),
SchemaId = refSchemaId,
AppId = AppId,
SchemaId = refSchemaId,
Status = Status.Draft,
StatusColor = null!,
Version = version
};
}

123
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs

@ -12,8 +12,8 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.Queries;
@ -30,12 +30,9 @@ public class ScriptContentTests : GivenContext
[Fact]
public async Task Should_not_call_script_engine_if_no_script_configured()
{
var (provider, schemaId) = CreateSchema(
queryPre: "my-pre-query");
var content = CreateContent();
var content = new ContentEntity { Data = new ContentData(), SchemaId = schemaId };
await sut.EnrichAsync(ApiContext, new[] { content }, provider, default);
await sut.EnrichAsync(ApiContext, new[] { content }, SchemaProvider(), CancellationToken);
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
@ -44,56 +41,100 @@ public class ScriptContentTests : GivenContext
[Fact]
public async Task Should_not_call_script_engine_for_frontend_user()
{
var (provider, schemaId) = CreateSchema(
query: "my-query");
SetupScript(query: "my-query");
var content = new ContentEntity { Data = new ContentData(), SchemaId = schemaId };
var content = CreateContent();
await sut.EnrichAsync(FrontendContext, new[] { content }, provider, default);
await sut.EnrichAsync(FrontendContext, new[] { content }, SchemaProvider(), CancellationToken);
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_call_script_engine_with_data()
public async Task Should_not_call_script_engine_if_disabled_and_user_has_permission()
{
var oldData = new ContentData();
SetupScript(query: "my-query");
var content = CreateContent();
await sut.EnrichAsync(ContextWithNoScript(), new[] { content }, SchemaProvider(), CancellationToken);
var (provider, schemaId) = CreateSchema(
query: "my-query");
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
var content = new ContentEntity { Data = oldData, SchemaId = schemaId };
[Fact]
public async Task Should_call_script_engine()
{
SetupScript(query: "my-query");
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, "my-query", ScriptOptions(), A<CancellationToken>._))
.Returns(new ContentData());
var contentBefore = CreateContent();
var contentData = contentBefore.Data;
await sut.EnrichAsync(ApiContext, new[] { content }, provider, default);
await sut.EnrichAsync(ApiContext, new[] { contentBefore }, SchemaProvider(), CancellationToken);
Assert.NotSame(oldData, content.Data);
Assert.NotSame(contentBefore.Data, contentData);
A.CallTo(() => scriptEngine.TransformAsync(
A<DataScriptVars>.That.Matches(x =>
Equals(x["contentId"], content.Id) &&
Equals(x["data"], oldData) &&
Equals(x["contentId"], contentBefore.Id) &&
Equals(x["data"], contentData) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(), A<CancellationToken>._))
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_script_engine_with_pre_query_script()
{
SetupScript(query: "my-query", queryPre: "my-pre-query");
var contentBefore = CreateContent();
var contentData = contentBefore.Data;
await sut.EnrichAsync(ApiContext, new[] { contentBefore }, SchemaProvider(), CancellationToken);
Assert.NotSame(contentBefore.Data, contentData);
A.CallTo(() => scriptEngine.ExecuteAsync(
A<DataScriptVars>.That.Matches(x =>
Equals(x.GetValue<object>("contentId"), null) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-pre-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
A.CallTo(() => scriptEngine.TransformAsync(
A<DataScriptVars>.That.Matches(x =>
Equals(x["contentId"], contentBefore.Id) &&
Equals(x["data"], contentData) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_make_test_with_pre_query_script()
{
var (provider, id) = CreateSchema(
SetupScript(
query: @"
ctx.data.test = { iv: ctx.custom };
replace()",
queryPre: "ctx.custom = 123;");
var content = new ContentEntity { Data = new ContentData(), SchemaId = id };
var content = CreateContent();
var realScriptEngine =
new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())),
@ -105,31 +146,43 @@ public class ScriptContentTests : GivenContext
var sut2 = new ScriptContent(realScriptEngine);
await sut2.EnrichAsync(ApiContext, new[] { content }, provider, default);
await sut2.EnrichAsync(ApiContext, new[] { content }, SchemaProvider(), CancellationToken);
Assert.Equal(JsonValue.Create(123), content.Data["test"]!["iv"]);
}
private (ProvideSchema, NamedId<DomainId>) CreateSchema(string? query = null, string? queryPre = null)
private void SetupScript(string? query = null, string? queryPre = null)
{
var id = NamedId.Of(DomainId.NewGuid(), "my-schema");
return (__ =>
{
var schemaDef =
new Schema(id.Name)
A.CallTo(() => Schema.SchemaDef)
.Returns(
new Schema(SchemaId.Name)
.SetScripts(new SchemaScripts
{
Query = query,
QueryPre = queryPre
});
}));
}
return Task.FromResult((Mocks.Schema(AppId, id, schemaDef), ResolvedComponents.Empty));
}, id);
private ContentEntity CreateContent()
{
return new ContentEntity { Data = new ContentData(), SchemaId = SchemaId };
}
private ProvideSchema SchemaProvider()
{
return x => Task.FromResult((Schema, ResolvedComponents.Empty));
}
private static ScriptOptions ScriptOptions()
{
return A<ScriptOptions>.That.Matches(x => x.AsContext);
}
private Context ContextWithNoScript()
{
var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id;
var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting());
return contextInstance;
}
}

4
backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs

@ -39,7 +39,7 @@ public class DefaultDomainObjectCacheTests
A.CallTo(() => cache.CreateEntry($"{id}_10"))
.MustHaveHappened();
A.CallTo(() => serializer.Serialize(20, A<Stream>._, true))
A.CallTo(() => serializer.Serialize(20, A<Stream>._, false))
.MustHaveHappened();
A.CallTo(() => distributedCache.SetAsync($"{id}_10", A<byte[]>._, A<DistributedCacheEntryOptions>._, ct))
@ -72,7 +72,7 @@ public class DefaultDomainObjectCacheTests
[Fact]
public async Task Should_provide_from_distributed_cache_if_not_found_in_cache()
{
A.CallTo(() => serializer.Deserialize<int>(A<Stream>._, null, false))
A.CallTo(() => serializer.Deserialize<int>(A<Stream>._, null))
.Returns(20);
var actual = await sut.GetAsync<int>(id, 10, ct);

2
frontend/src/app/features/content/pages/content/editor/content-field.component.html

@ -4,7 +4,7 @@
<div class="languages-container">
<div class="languages-buttons">
<div class="languages-inner">
<button *ngIf="!formModel.field.isDisabled && isTranslatable" type="button" class="btn btn-text-secondary btn-sm me-1" title="i18n:contents.autotranslate" (click)="translate()" tabindex="-1">
<button *ngIf="!formModel.field.isDisabled && isTranslatable" type="button" class="btn btn-text-secondary btn-sm no-focus-shadow me-1" title="i18n:contents.autotranslate" (click)="translate()" tabindex="-1">
<i class="icon-translate"></i>
</button>

2
frontend/src/app/features/content/pages/content/editor/field-languages.component.html

@ -1,5 +1,5 @@
<ng-container *ngIf="formModel.field.isLocalizable && languages.length > 1">
<button *ngIf="!formModel.field.properties.isComplexUI" type="button" class="btn btn-text-secondary btn-sm me-1" (click)="toggleShowAllControls()">
<button *ngIf="!formModel.field.properties.isComplexUI" type="button" class="btn btn-text-secondary btn-sm no-focus-shadow me-1" (click)="toggleShowAllControls()">
<ng-container *ngIf="showAllControls; else singleLanguage">
<span>{{ 'contents.languageModeSingle' | sqxTranslate }}</span>
</ng-container>

4
frontend/src/app/features/settings/pages/workflows/workflow.component.html

@ -7,9 +7,9 @@
<sqx-tag-editor
[itemConverter]="(schemasSource.normalConverter | async)!"
[ngModel]="workflow.schemaIds"
[readonly]="true">
[readonly]="true"
[styleBlank]="true"
[styleScrollable]="true"
[styleScrollable]="true">
</sqx-tag-editor>
</div>
<div class="col-options">

5
frontend/src/app/framework/angular/pager.component.html

@ -5,6 +5,11 @@
</select>
<span class="page-info d-flex align-items-center justify-content-end">
<span class="btn deactivated">
&nbsp;
</span>
&nbsp;
<ng-container *ngIf="paging.count > 0 && paging.total > 0">
<button class="btn deactivated">
{{ 'common.pagerInfo' | sqxTranslate: translationInfo }}

23
frontend/src/app/theme/_bootstrap.scss

@ -393,7 +393,18 @@ a {
display: none;
}
&.active {
&:hover {
color: $color-theme-brand;
border-color: $color-border;
border-radius: $border-radius;
i {
color: $color-theme-brand;
}
}
&.active,
&:active {
background: none;
border-color: $color-theme-brand;
border-radius: $border-radius;
@ -411,16 +422,6 @@ a {
}
}
}
&:hover {
color: $color-theme-brand;
border-color: $color-border;
border-radius: $border-radius;
i {
color: $color-theme-brand;
}
}
}
// Special button groups

6
frontend/src/app/theme/_common.scss

@ -266,6 +266,12 @@ hr {
}
}
.no-focus-shadow {
&:focus {
box-shadow: none;
}
}
.sidebar {
@include absolute($size-navbar-height, null, 0, 0);
background: $color-theme-brand;

4
frontend/src/app/theme/_mixins.scss

@ -42,6 +42,10 @@
&:hover {
color: darken($color, 15%);
}
&:active {
border-color: transparent !important;
}
}
@mixin scrollbars($size, $foreground-color, $background-color: mix($foreground-color, white, 50%)) {

29
tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs

@ -8,7 +8,6 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.ClientLibrary;
using Squidex.ClientLibrary.Utils;
using TestSuite.Model;
#pragma warning disable SA1300 // Element should begin with upper-case letter
@ -117,6 +116,34 @@ public class ContentQueryTests : IClassFixture<ContentQueryFixture>
AssertItems(items_1, 3, new[] { 4, 5, 6 });
}
[Fact]
public async Task Should_query_with_all()
{
var values = new List<int>();
await _.Contents.GetAllAsync(content =>
{
values.Add(content.Data.Number);
return Task.CompletedTask;
});
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, values.OrderBy(x => x).ToArray());
}
[Fact]
public async Task Should_query_with_streaming()
{
var values = new List<int>();
await _.Contents.StreamAllAsync(content =>
{
values.Add(content.Data.Number);
return Task.CompletedTask;
});
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, values.OrderBy(x => x).ToArray());
}
[Fact]
public async Task Should_query_all_with_odata()
{

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

@ -24,7 +24,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="NSwag.Core" Version="13.18.2" />
<PackageReference Include="PuppeteerSharp" Version="8.0.0" />
<PackageReference Include="Squidex.Assets" Version="5.3.0" />
<PackageReference Include="Squidex.Assets" Version="5.4.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify.Xunit" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />

4
tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -16,8 +16,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="14.1.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="14.1.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="14.2.0" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="14.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />

Loading…
Cancel
Save