Browse Source

Graphql memory cache. (#851)

* Graphql memory cache.

* Mini improvement.

* Fix registrations.

* Simplification.

* Just some reformatting.
pull/852/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
4f8d6d091b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/IEnrichedEntityEvent.cs
  2. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs
  3. 22
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCache.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs
  5. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  6. 16
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCache.cs
  7. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs
  8. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentCache.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs
  10. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs
  11. 63
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  12. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs
  13. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs
  14. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentGraphType.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs
  17. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  18. 27
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs
  19. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs
  20. 72
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs
  21. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentCache.cs
  22. 81
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
  23. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  24. 4
      backend/src/Squidex.Domain.Apps.Entities/IEntity.cs
  25. 18
      backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs
  26. 94
      backend/src/Squidex.Infrastructure/Caching/QueryCache.cs
  27. 14
      backend/src/Squidex.Infrastructure/IWithId.cs
  28. 3
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs
  29. 3
      backend/src/Squidex/Config/Domain/AssetServices.cs
  30. 3
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  31. 3
      backend/src/Squidex/Config/Domain/QueryServices.cs
  32. 10
      backend/src/Squidex/appsettings.json
  33. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs
  34. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  35. 116
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  36. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  37. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs
  38. 180
      backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs

3
backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/IEnrichedEntityEvent.cs

@ -9,8 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents
{ {
public interface IEnrichedEntityEvent public interface IEnrichedEntityEvent : IWithId<DomainId>
{ {
DomainId Id { get; }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules
[BsonElement] [BsonElement]
public Instant? NextAttempt { get; set; } public Instant? NextAttempt { get; set; }
DomainId IEntity.Id DomainId IWithId<DomainId>.Id
{ {
get => JobId; get => JobId;
} }

22
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCache.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCache : QueryCache<DomainId, IEnrichedAssetEntity>, IAssetCache
{
public AssetCache(IMemoryCache? memoryCache, IOptions<AssetOptions> options)
: base(options.Value.CanCache ? memoryCache : null)
{
}
}
}

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

@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public bool FolderPerApp { get; set; } = false; public bool FolderPerApp { get; set; } = false;
public bool CanCache { get; set; }
public int DefaultPageSize { get; set; } = 200; public int DefaultPageSize { get; set; } = 200;
public int MaxResults { get; set; } = 200; public int MaxResults { get; set; } = 200;

1
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -67,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
memberAccessStrategy.Register<IAssetEntity>(); memberAccessStrategy.Register<IAssetEntity>();
memberAccessStrategy.Register<IAssetInfo>(); memberAccessStrategy.Register<IAssetInfo>();
memberAccessStrategy.Register<IWithId<DomainId>>();
memberAccessStrategy.Register<IEntity>(); memberAccessStrategy.Register<IEntity>();
memberAccessStrategy.Register<IEntityWithCreatedBy>(); memberAccessStrategy.Register<IEntityWithCreatedBy>();
memberAccessStrategy.Register<IEntityWithLastModifiedBy>(); memberAccessStrategy.Register<IEntityWithLastModifiedBy>();

16
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCache.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetCache : IQueryCache<DomainId, IEnrichedAssetEntity>
{
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs

@ -10,10 +10,8 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup namespace Squidex.Domain.Apps.Entities.Backup
{ {
public interface IBackupJob public interface IBackupJob : IWithId<DomainId>
{ {
DomainId Id { get; }
Instant Started { get; } Instant Started { get; }
Instant? Stopped { get; } Instant? Stopped { get; }

22
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentCache.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentCache : QueryCache<DomainId, IEnrichedContentEntity>, IContentCache
{
public ContentCache(IMemoryCache? memoryCache, IOptions<ContentOptions> options)
: base(options.Value.CanCache ? memoryCache : null)
{
}
}
}

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

@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentOptions public sealed class ContentOptions
{ {
public bool CanCache { get; set; }
public int DefaultPageSize { get; set; } = 200; public int DefaultPageSize { get; set; } = 200;
public int MaxResults { get; set; } = 200; public int MaxResults { get; set; } = 200;

6
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs

@ -30,7 +30,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private readonly ISchemasHash schemasHash; private readonly ISchemasHash schemasHash;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly GraphQLOptions options; private readonly GraphQLOptions options;
private readonly SharedTypes sharedTypes;
private sealed record CacheEntry(GraphQLSchema Model, string Hash, Instant Created); private sealed record CacheEntry(GraphQLSchema Model, string Hash, Instant Created);
@ -46,8 +45,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
this.schemasHash = schemasHash; this.schemasHash = schemasHash;
this.serviceProvider = serviceProvider; this.serviceProvider = serviceProvider;
this.options = options.Value; this.options = options.Value;
sharedTypes = serviceProvider.GetRequiredService<SharedTypes>();
} }
public async Task ConfigureAsync(ExecutionOptions executionOptions) public async Task ConfigureAsync(ExecutionOptions executionOptions)
@ -91,8 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var hash = await schemasHash.ComputeHashAsync(app, schemas); var hash = await schemasHash.ComputeHashAsync(app, schemas);
return new CacheEntry(new Builder(app, sharedTypes).BuildSchema(schemas), return new CacheEntry(new Builder(app).BuildSchema(schemas), hash, SystemClock.Instance.GetCurrentInstant());
hash, SystemClock.Instance.GetCurrentInstant());
} }
private static object CreateCacheKey(DomainId appId, string etag) private static object CreateCacheKey(DomainId appId, string etag)

63
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -22,8 +22,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public override Context Context { get; } public override Context Context { get; }
public GraphQLExecutionContext(IServiceProvider serviceProvider, IDataLoaderContextAccessor dataLoaders, Context context) public GraphQLExecutionContext(
: base(serviceProvider) IDataLoaderContextAccessor dataLoaders,
IAssetQueryService assetQuery,
IAssetCache assetCache,
IContentQueryService contentQuery,
IContentCache contentCache,
IServiceProvider serviceProvider,
Context context)
: base(assetQuery, assetCache, contentQuery, contentCache, serviceProvider)
{ {
this.dataLoaders = dataLoaders; this.dataLoaders = dataLoaders;
@ -70,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return content; return content;
} }
public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value, public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration,
CancellationToken ct) CancellationToken ct)
{ {
var ids = ParseIds(value); var ids = ParseIds(value);
@ -80,14 +87,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return EmptyAssets; return EmptyAssets;
} }
var dataLoader = GetAssetsLoader(); async Task<IReadOnlyList<IEnrichedAssetEntity>> LoadAsync(IEnumerable<DomainId> ids)
{
var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); var result = await GetAssetsLoader().LoadAsync(ids).GetResultAsync(ct);
return result?.NotNull().ToList() ?? EmptyAssets; return result?.NotNull().ToList() ?? EmptyAssets;
} }
public async Task<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(IJsonValue value, if (cacheDuration > TimeSpan.Zero)
{
var assets = await AssetCache.CacheOrQueryAsync(ids, async pendingIds =>
{
return await LoadAsync(pendingIds);
}, cacheDuration);
return assets;
}
return await LoadAsync(ids);
}
public async Task<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration,
CancellationToken ct) CancellationToken ct)
{ {
var ids = ParseIds(value); var ids = ParseIds(value);
@ -97,13 +117,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return EmptyContents; return EmptyContents;
} }
var dataLoader = GetContentsLoader(); async Task<IReadOnlyList<IEnrichedContentEntity>> LoadAsync(IEnumerable<DomainId> ids)
{
var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); var result = await GetContentsLoader().LoadAsync(ids).GetResultAsync(ct);
return result?.NotNull().ToList() ?? EmptyContents; return result?.NotNull().ToList() ?? EmptyContents;
} }
if (cacheDuration > TimeSpan.Zero)
{
var contents = await ContentCache.CacheOrQueryAsync(ids, async pendingIds =>
{
return await LoadAsync(pendingIds);
}, cacheDuration);
return contents.ToList();
}
return await LoadAsync(ids);
}
private IDataLoader<DomainId, IEnrichedAssetEntity> GetAssetsLoader() private IDataLoader<DomainId, IEnrichedAssetEntity> GetAssetsLoader()
{ {
return dataLoaders.Context.GetOrAddBatchLoader<DomainId, IEnrichedAssetEntity>(nameof(GetAssetsLoader), return dataLoaders.Context.GetOrAddBatchLoader<DomainId, IEnrichedAssetEntity>(nameof(GetAssetsLoader),
@ -137,17 +170,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}); });
} }
private static ICollection<DomainId>? ParseIds(IJsonValue value) private static List<DomainId>? ParseIds(IJsonValue value)
{ {
try try
{ {
var result = new List<DomainId>(); List<DomainId>? result = null;
if (value is JsonArray array) if (value is JsonArray array)
{ {
foreach (var id in array) foreach (var id in array)
{ {
result.Add(DomainId.Create(id.ToString())); if (id is JsonString jsonString)
{
result ??= new List<DomainId>();
result.Add(DomainId.Create(jsonString.Value));
}
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs

@ -14,9 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public AppQueriesGraphType(Builder builder, IEnumerable<SchemaInfo> schemaInfos) public AppQueriesGraphType(Builder builder, IEnumerable<SchemaInfo> schemaInfos)
{ {
AddField(builder.SharedTypes.FindAsset); AddField(SharedTypes.FindAsset);
AddField(builder.SharedTypes.QueryAssets); AddField(SharedTypes.QueryAssets);
AddField(builder.SharedTypes.QueryAssetsWithTotal); AddField(SharedTypes.QueryAssetsWithTotal);
foreach (var schemaInfo in schemaInfos) foreach (var schemaInfo in schemaInfos)
{ {

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
{ {
internal sealed class AssetGraphType : ObjectGraphType<IEnrichedAssetEntity> internal sealed class AssetGraphType : ObjectGraphType<IEnrichedAssetEntity>
{ {
public AssetGraphType(bool canGenerateSourceUrl) public AssetGraphType()
{ {
// The name is used for equal comparison. Therefore it is important to treat it as readonly. // The name is used for equal comparison. Therefore it is important to treat it as readonly.
Name = "Asset"; Name = "Asset";
@ -235,8 +235,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
Description = FieldDescriptions.EditToken Description = FieldDescriptions.EditToken
}); });
if (canGenerateSourceUrl)
{
AddField(new FieldType AddField(new FieldType
{ {
Name = "sourceUrl", Name = "sourceUrl",
@ -244,7 +242,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
Resolver = SourceUrl, Resolver = SourceUrl,
Description = FieldDescriptions.AssetSourceUrl Description = FieldDescriptions.AssetSourceUrl
}); });
}
Description = "An asset"; Description = "An asset";
} }

8
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs

@ -30,18 +30,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private readonly PartitionResolver partitionResolver; private readonly PartitionResolver partitionResolver;
private readonly List<SchemaInfo> allSchemas = new List<SchemaInfo>(); private readonly List<SchemaInfo> allSchemas = new List<SchemaInfo>();
public SharedTypes SharedTypes { get; }
static Builder() static Builder()
{ {
ValueConverter.Register<string, DomainId>(DomainId.Create); ValueConverter.Register<string, DomainId>(DomainId.Create);
ValueConverter.Register<string, Status>(x => new Status(x)); ValueConverter.Register<string, Status>(x => new Status(x));
} }
public Builder(IAppEntity app, SharedTypes sharedTypes) public Builder(IAppEntity app)
{ {
SharedTypes = sharedTypes;
partitionResolver = app.PartitionResolver(); partitionResolver = app.PartitionResolver();
fieldVisitor = new FieldVisitor(this); fieldVisitor = new FieldVisitor(this);
@ -79,6 +75,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
newSchema.RegisterType(SharedTypes.ComponentInterface); newSchema.RegisterType(SharedTypes.ComponentInterface);
newSchema.RegisterType(SharedTypes.ContentInterface); newSchema.RegisterType(SharedTypes.ContentInterface);
newSchema.Directives.Register(SharedTypes.MemoryCacheDirective);
if (schemaInfos.Any()) if (schemaInfos.Any())
{ {
var mutations = new AppMutationsGraphType(this, schemaInfos); var mutations = new AppMutationsGraphType(this, schemaInfos);

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentGraphType.cs

@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
} }
} }
AddResolvedInterface(builder.SharedTypes.ComponentInterface); AddResolvedInterface(SharedTypes.ComponentInterface);
} }
private static Func<object, bool> CheckType(string schemaId) private static Func<object, bool> CheckType(string schemaId)

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs

@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
AddReferencesQueries(builder, other); AddReferencesQueries(builder, other);
} }
AddResolvedInterface(builder.SharedTypes.ContentInterface); AddResolvedInterface(SharedTypes.ContentInterface);
Description = $"The structure of a {schemaInfo.DisplayName} content type."; Description = $"The structure of a {schemaInfo.DisplayName} content type.";
} }

10
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs

@ -81,12 +81,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
private static readonly IFieldResolver Assets = CreateValueResolver((value, fieldContext, context) => private static readonly IFieldResolver Assets = CreateValueResolver((value, fieldContext, context) =>
{ {
return context.GetReferencedAssetsAsync(value, fieldContext.CancellationToken); var cacheDuration = fieldContext.CacheDuration();
return context.GetReferencedAssetsAsync(value, cacheDuration, fieldContext.CancellationToken);
}); });
private static readonly IFieldResolver References = CreateValueResolver((value, fieldContext, context) => private static readonly IFieldResolver References = CreateValueResolver((value, fieldContext, context) =>
{ {
return context.GetReferencedContentsAsync(value, fieldContext.CancellationToken); var cacheDuration = fieldContext.CacheDuration();
return context.GetReferencedContentsAsync(value, cacheDuration, fieldContext.CancellationToken);
}); });
private readonly Builder builder; private readonly Builder builder;
@ -115,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<AssetsFieldProperties> field, FieldInfo args) public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<AssetsFieldProperties> field, FieldInfo args)
{ {
return (builder.SharedTypes.AssetsList, Assets, null); return (SharedTypes.AssetsList, Assets, null);
} }
public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<BooleanFieldProperties> field, FieldInfo args) public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField<BooleanFieldProperties> field, FieldInfo args)

27
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.Types;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives
{
public sealed class CacheDirective : DirectiveGraphType
{
public CacheDirective()
: base("cache", DirectiveLocation.Field, DirectiveLocation.FragmentSpread, DirectiveLocation.InlineFragment)
{
Description = "Enable Memory Caching";
Arguments = new QueryArguments(new QueryArgument<IntGraphType>
{
Name = "duration",
Description = "Cache duration in seconds.",
DefaultValue = 600
});
}
}
}

20
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using GraphQL; using GraphQL;
using GraphQL.Language.AST;
using GraphQL.Types; using GraphQL.Types;
using GraphQL.Utilities; using GraphQL.Utilities;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
@ -15,7 +16,7 @@ using Squidex.Infrastructure.ObjectPool;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public static class Extensions public static class SharedExtensions
{ {
internal static string BuildODataQuery(this IResolveFieldContext context) internal static string BuildODataQuery(this IResolveFieldContext context)
{ {
@ -123,5 +124,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
return type; return type;
} }
public static TimeSpan CacheDuration(this IResolveFieldContext context)
{
var cacheDirective = context.FieldAst.Directives?.Find("cache");
if (cacheDirective != null)
{
var duration = cacheDirective.Arguments?.ValueFor("duration");
if (duration is IntValue value && value.Value > 0)
{
return TimeSpan.FromSeconds(value.Value);
}
}
return TimeSpan.Zero;
}
} }
} }

72
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs

@ -6,69 +6,27 @@
// ========================================================================== // ==========================================================================
using GraphQL.Types; using GraphQL.Types;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public sealed class SharedTypes public static class SharedTypes
{ {
private readonly Lazy<IGraphType> asset; public static readonly IGraphType Asset = new AssetGraphType();
private readonly Lazy<IGraphType> assetsList;
private readonly Lazy<IGraphType> assetsResult;
private readonly Lazy<IInterfaceGraphType> contentInterface;
private readonly Lazy<IInterfaceGraphType> componentInterface;
private readonly Lazy<FieldType> findAsset;
private readonly Lazy<FieldType> queryAssets;
private readonly Lazy<FieldType> queryAssetsWithTotal;
public IGraphType Asset => asset.Value; public static readonly IGraphType AssetsList = new ListGraphType(new NonNullGraphType(Asset));
public IGraphType AssetsList => assetsList.Value; public static readonly IGraphType AssetsResult = new AssetsResultGraphType(AssetsList);
public IGraphType AssetsResult => assetsResult.Value; public static readonly IInterfaceGraphType ContentInterface = new ContentInterfaceGraphType();
public IInterfaceGraphType ContentInterface => contentInterface.Value; public static readonly IInterfaceGraphType ComponentInterface = new ComponentInterfaceGraphType();
public IInterfaceGraphType ComponentInterface => componentInterface.Value; public static readonly CacheDirective MemoryCacheDirective = new CacheDirective();
public FieldType FindAsset => findAsset.Value; public static readonly FieldType FindAsset = new FieldType
public FieldType QueryAssets => queryAssets.Value;
public FieldType QueryAssetsWithTotal => queryAssetsWithTotal.Value;
public SharedTypes(IUrlGenerator urlGenerator)
{
asset = new Lazy<IGraphType>(() =>
{
return new AssetGraphType(urlGenerator.CanGenerateAssetSourceUrl);
});
assetsList = new Lazy<IGraphType>(() =>
{
return new ListGraphType(new NonNullGraphType(Asset));
});
assetsResult = new Lazy<IGraphType>(() =>
{
return new AssetsResultGraphType(AssetsList);
});
contentInterface = new Lazy<IInterfaceGraphType>(() =>
{
return new ContentInterfaceGraphType();
});
componentInterface = new Lazy<IInterfaceGraphType>(() =>
{
return new ComponentInterfaceGraphType();
});
findAsset = new Lazy<FieldType>(() =>
{
return new FieldType
{ {
Name = "findAsset", Name = "findAsset",
Arguments = AssetActions.Find.Arguments, Arguments = AssetActions.Find.Arguments,
@ -76,11 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Resolver = AssetActions.Find.Resolver, Resolver = AssetActions.Find.Resolver,
Description = "Find an asset by id." Description = "Find an asset by id."
}; };
});
queryAssets = new Lazy<FieldType>(() => public static readonly FieldType QueryAssets = new FieldType
{
return new FieldType
{ {
Name = "queryAssets", Name = "queryAssets",
Arguments = AssetActions.Query.Arguments, Arguments = AssetActions.Query.Arguments,
@ -88,11 +43,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Resolver = AssetActions.Query.Resolver, Resolver = AssetActions.Query.Resolver,
Description = "Get assets." Description = "Get assets."
}; };
});
queryAssetsWithTotal = new Lazy<FieldType>(() => public static readonly FieldType QueryAssetsWithTotal = new FieldType
{
return new FieldType
{ {
Name = "queryAssetsWithTotal", Name = "queryAssetsWithTotal",
Arguments = AssetActions.Query.Arguments, Arguments = AssetActions.Query.Arguments,
@ -100,7 +52,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Resolver = AssetActions.Query.ResolverWithTotal, Resolver = AssetActions.Query.ResolverWithTotal,
Description = "Get assets and total count." Description = "Get assets and total count."
}; };
});
}
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentCache.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentCache : IQueryCache<DomainId, IEnrichedContentEntity>
{
}
}

81
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -15,24 +14,40 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public abstract class QueryExecutionContext : Dictionary<string, object> public abstract class QueryExecutionContext : Dictionary<string, object>
{ {
private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10);
private readonly ConcurrentDictionary<DomainId, IEnrichedContentEntity?> cachedContents = new ConcurrentDictionary<DomainId, IEnrichedContentEntity?>();
private readonly ConcurrentDictionary<DomainId, IEnrichedAssetEntity?> cachedAssets = new ConcurrentDictionary<DomainId, IEnrichedAssetEntity?>();
public abstract Context Context { get; } public abstract Context Context { get; }
protected IAssetQueryService AssetQuery { get; }
protected IAssetCache AssetCache { get; }
protected IContentCache ContentCache { get; }
protected IContentQueryService ContentQuery { get; }
public IServiceProvider Services { get; } public IServiceProvider Services { get; }
protected QueryExecutionContext(IServiceProvider serviceProvider) protected QueryExecutionContext(
IAssetQueryService assetQuery,
IAssetCache assetCache,
IContentQueryService contentQuery,
IContentCache contentCache,
IServiceProvider serviceProvider)
{ {
Guard.NotNull(serviceProvider); Guard.NotNull(serviceProvider);
AssetQuery = assetQuery;
AssetCache = assetCache;
ContentQuery = contentQuery;
ContentCache = contentCache;
Services = serviceProvider; Services = serviceProvider;
} }
public virtual Task<IEnrichedContentEntity?> FindContentAsync(string schemaIdOrName, DomainId id, long version, public virtual Task<IEnrichedContentEntity?> FindContentAsync(string schemaIdOrName, DomainId id, long version,
CancellationToken ct) CancellationToken ct)
{ {
return Resolve<IContentQueryService>().FindAsync(Context, schemaIdOrName, id, version, ct); return ContentQuery.FindAsync(Context, schemaIdOrName, id, version, ct);
} }
public virtual async Task<IResultList<IEnrichedAssetEntity>> QueryAssetsAsync(Q q, public virtual async Task<IResultList<IEnrichedAssetEntity>> QueryAssetsAsync(Q q,
@ -43,17 +58,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
await maxRequests.WaitAsync(ct); await maxRequests.WaitAsync(ct);
try try
{ {
assets = await Resolve<IAssetQueryService>().QueryAsync(Context, null, q, ct); assets = await AssetQuery.QueryAsync(Context, null, q, ct);
} }
finally finally
{ {
maxRequests.Release(); maxRequests.Release();
} }
foreach (var asset in assets) AssetCache.SetMany(assets.Select(x => (x.Id, x))!);
{
cachedAssets[asset.Id] = asset;
}
return assets; return assets;
} }
@ -66,83 +78,58 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
await maxRequests.WaitAsync(ct); await maxRequests.WaitAsync(ct);
try try
{ {
contents = await Resolve<IContentQueryService>().QueryAsync(Context, schemaIdOrName, q, ct); contents = await ContentQuery.QueryAsync(Context, schemaIdOrName, q, ct);
} }
finally finally
{ {
maxRequests.Release(); maxRequests.Release();
} }
foreach (var content in contents) ContentCache.SetMany(contents.Select(x => (x.Id, x))!);
{
cachedContents[content.Id] = content;
}
return contents; return contents;
} }
public virtual async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(ICollection<DomainId> ids, public virtual async Task<IReadOnlyList<IEnrichedAssetEntity>> GetReferencedAssetsAsync(IEnumerable<DomainId> ids,
CancellationToken ct) CancellationToken ct)
{ {
Guard.NotNull(ids); Guard.NotNull(ids);
var notLoadedAssets = new HashSet<DomainId>(ids.Where(id => !cachedAssets.ContainsKey(id))); return await AssetCache.CacheOrQueryAsync(ids, async pendingIds =>
if (notLoadedAssets.Count > 0)
{ {
IResultList<IEnrichedAssetEntity> assets;
await maxRequests.WaitAsync(ct); await maxRequests.WaitAsync(ct);
try try
{ {
var q = Q.Empty.WithIds(notLoadedAssets).WithoutTotal(); var q = Q.Empty.WithIds(pendingIds).WithoutTotal();
assets = await Resolve<IAssetQueryService>().QueryAsync(Context, null, q, ct); return await AssetQuery.QueryAsync(Context, null, q, ct);
} }
finally finally
{ {
maxRequests.Release(); maxRequests.Release();
} }
});
foreach (var asset in assets)
{
cachedAssets[asset.Id] = asset;
}
}
return ids.Select(cachedAssets.GetOrDefault).NotNull().ToList();
} }
public virtual async Task<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(ICollection<DomainId> ids, public virtual async Task<IReadOnlyList<IEnrichedContentEntity>> GetReferencedContentsAsync(IEnumerable<DomainId> ids,
CancellationToken ct) CancellationToken ct)
{ {
Guard.NotNull(ids); Guard.NotNull(ids);
var notLoadedContents = ids.Where(id => !cachedContents.ContainsKey(id)).ToList(); return await ContentCache.CacheOrQueryAsync(ids, async pendingIds =>
if (notLoadedContents.Count > 0)
{ {
IResultList<IEnrichedContentEntity> contents;
await maxRequests.WaitAsync(ct); await maxRequests.WaitAsync(ct);
try try
{ {
var q = Q.Empty.WithIds(notLoadedContents).WithoutTotal(); var q = Q.Empty.WithIds(pendingIds).WithoutTotal();
contents = await Resolve<IContentQueryService>().QueryAsync(Context, q, ct); return await ContentQuery.QueryAsync(Context, q, ct);
} }
finally finally
{ {
maxRequests.Release(); maxRequests.Release();
} }
});
foreach (var content in contents)
{
cachedContents[content.Id] = content;
}
}
return ids.Select(cachedContents.GetOrDefault).NotNull().ToList();
} }
public T Resolve<T>() where T : class public T Resolve<T>() where T : class

1
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs

@ -62,6 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy)
{ {
memberAccessStrategy.Register<IContentEntity>(); memberAccessStrategy.Register<IContentEntity>();
memberAccessStrategy.Register<IWithId<DomainId>>();
memberAccessStrategy.Register<IEntity>(); memberAccessStrategy.Register<IEntity>();
memberAccessStrategy.Register<IEntityWithCreatedBy>(); memberAccessStrategy.Register<IEntityWithCreatedBy>();
memberAccessStrategy.Register<IEntityWithLastModifiedBy>(); memberAccessStrategy.Register<IEntityWithLastModifiedBy>();

4
backend/src/Squidex.Domain.Apps.Entities/IEntity.cs

@ -10,10 +10,8 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities namespace Squidex.Domain.Apps.Entities
{ {
public interface IEntity public interface IEntity : IWithId<DomainId>
{ {
DomainId Id { get; }
Instant Created { get; } Instant Created { get; }
Instant LastModified { get; } Instant LastModified { get; }

18
backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Caching
{
public interface IQueryCache<TKey, T> where TKey : notnull where T : class, IWithId<TKey>
{
void SetMany(IEnumerable<(TKey, T?)> results,
TimeSpan? permanentDuration = null);
Task<List<T>> CacheOrQueryAsync(IEnumerable<TKey> keys, Func<IEnumerable<TKey>, Task<IEnumerable<T>>> query,
TimeSpan? permanentDuration = null);
}
}

94
backend/src/Squidex.Infrastructure/Caching/QueryCache.cs

@ -0,0 +1,94 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
namespace Squidex.Infrastructure.Caching
{
public class QueryCache<TKey, T> : IQueryCache<TKey, T> where TKey : notnull where T : class, IWithId<TKey>
{
private readonly ConcurrentDictionary<TKey, T?> entries = new ConcurrentDictionary<TKey, T?>();
private readonly IMemoryCache? memoryCache;
public QueryCache(IMemoryCache? memoryCache = null)
{
this.memoryCache = memoryCache;
}
public void SetMany(IEnumerable<(TKey, T?)> results,
TimeSpan? permanentDuration = null)
{
Guard.NotNull(results);
foreach (var (key, value) in results)
{
Set(key, value, permanentDuration);
}
}
private void Set(TKey key, T? value,
TimeSpan? permanentDuration = null)
{
entries[key] = value;
if (memoryCache != null && permanentDuration > TimeSpan.Zero)
{
memoryCache.Set(key, value, permanentDuration.Value);
}
}
public async Task<List<T>> CacheOrQueryAsync(IEnumerable<TKey> keys, Func<IEnumerable<TKey>, Task<IEnumerable<T>>> query,
TimeSpan? permanentDuration = null)
{
Guard.NotNull(keys);
Guard.NotNull(query);
var items = GetMany(keys, permanentDuration.HasValue);
var pendingIds = new HashSet<TKey>(keys.Where(key => !items.ContainsKey(key)));
if (pendingIds.Count > 0)
{
var queried = (await query(pendingIds)).ToDictionary(x => x.Id);
foreach (var id in pendingIds)
{
queried.TryGetValue(id, out var item);
items[id] = item;
Set(id, item, permanentDuration);
}
}
return items.Values.NotNull().ToList();
}
private Dictionary<TKey, T?> GetMany(IEnumerable<TKey> keys,
bool fromPermanentCache = false)
{
var result = new Dictionary<TKey, T?>();
foreach (var key in keys)
{
if (entries.TryGetValue(key, out var value))
{
result[key] = value;
}
else if (fromPermanentCache && memoryCache != null && memoryCache.TryGetValue(key, out value))
{
result[key] = value;
entries[key] = value;
}
}
return result;
}
}
}

14
backend/src/Squidex.Infrastructure/IWithId.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure
{
public interface IWithId<T>
{
T Id { get; }
}
}

3
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs

@ -34,7 +34,8 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
schema.DiscriminatorObject = new OpenApiDiscriminator schema.DiscriminatorObject = new OpenApiDiscriminator
{ {
JsonInheritanceConverter = new RuleActionConverter(), PropertyName = "actionType" JsonInheritanceConverter = new RuleActionConverter(),
PropertyName = "actionType"
}; };
schema.Properties["actionType"] = new JsonSchemaProperty schema.Properties["actionType"] = new JsonSchemaProperty

3
backend/src/Squidex/Config/Domain/AssetServices.cs

@ -54,6 +54,9 @@ namespace Squidex.Config.Domain
services.AddTransientAs<AssetTagsDeleter>() services.AddTransientAs<AssetTagsDeleter>()
.As<IDeleter>(); .As<IDeleter>();
services.AddTransientAs<AssetCache>()
.As<IAssetCache>();
services.AddSingletonAs<AssetTusRunner>() services.AddSingletonAs<AssetTusRunner>()
.AsSelf(); .AsSelf();

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

@ -40,6 +40,9 @@ namespace Squidex.Config.Domain
services.AddTransientAs<CounterDeleter>() services.AddTransientAs<CounterDeleter>()
.As<IDeleter>(); .As<IDeleter>();
services.AddTransientAs<ContentCache>()
.As<IContentCache>();
services.AddSingletonAs<DefaultValidatorsFactory>() services.AddSingletonAs<DefaultValidatorsFactory>()
.As<IValidatorsFactory>(); .As<IValidatorsFactory>();

3
backend/src/Squidex/Config/Domain/QueryServices.cs

@ -25,9 +25,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<UrlGenerator>(c, exposeSourceUrl)) services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<UrlGenerator>(c, exposeSourceUrl))
.As<IUrlGenerator>(); .As<IUrlGenerator>();
services.AddSingletonAs<SharedTypes>()
.AsSelf();
services.AddSingletonAs<InstantGraphType>() services.AddSingletonAs<InstantGraphType>()
.AsSelf(); .AsSelf();

10
backend/src/Squidex/appsettings.json

@ -212,6 +212,11 @@
}, },
"contents": { "contents": {
// True to enable memory caching.
//
// This is only supported in GraphQL with the @cache(duration: 1000) directive.
"canCache": true,
// The default page size if not specified by a query. // The default page size if not specified by a query.
"defaultPageSize": 200, "defaultPageSize": 200,
@ -228,6 +233,11 @@
}, },
"assets": { "assets": {
// True to enable memory caching.
//
// This is only supported in GraphQL with the @cache(duration: 1000) directive.
"canCache": true,
// The default page size if not specified by a query. // The default page size if not specified by a query.
"defaultPageSize": 200, "defaultPageSize": 200,

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs

@ -120,7 +120,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{ {
var command = new CreateComment var command = new CreateComment
{ {
Text = "Hi @invalid@squidex.io", IsMention = true Text = "Hi @invalid@squidex.io",
IsMention = true
}; };
var context = CrateCommandContext(command); var context = CrateCommandContext(command);

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

@ -360,7 +360,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
Schemas = ReadonlyList.Create( Schemas = ReadonlyList.Create(
new ContentChangedTriggerSchemaV2 new ContentChangedTriggerSchemaV2
{ {
SchemaId = schemaId.Id, Condition = condition SchemaId = schemaId.Id,
Condition = condition
}) })
}; };
} }

116
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -472,6 +472,122 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
AssertResult(expected, result); AssertResult(expected, result);
} }
[Fact]
public async Task Should_also_fetch_referenced_contents_from_flat_data_if_field_is_included_in_query()
{
var contentRefId = DomainId.NewGuid();
var contentRef = TestContent.CreateRef(TestSchemas.Ref1Id, contentRefId, "schemaRef1Field", "ref1");
var contentId = DomainId.NewGuid();
var content = TestContent.Create(contentId, contentRefId);
var query = CreateQuery(@"
query {
findMySchemaContent(id: '<ID>') {
id
flatData {
myReferences {
id
}
}
}
}", contentId);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentRefId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, content));
var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new
{
data = new
{
findMySchemaContent = new
{
id = content.Id,
flatData = new
{
myReferences = new[]
{
new
{
id = contentRefId
}
}
}
}
}
};
AssertResult(expected, result);
}
[Fact]
public async Task Should_cache_referenced_contents_from_flat_data_if_field_is_included_in_query()
{
var contentRefId = DomainId.NewGuid();
var contentRef = TestContent.CreateRef(TestSchemas.Ref1Id, contentRefId, "schemaRef1Field", "ref1");
var contentId = DomainId.NewGuid();
var content = TestContent.Create(contentId, contentRefId);
var query = CreateQuery(@"
query {
findMySchemaContent(id: '<ID>') {
id
flatData {
myReferences @cache(duration: 1000) {
id
}
}
}
}", contentId);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentRefId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(0, contentRef));
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentId), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(1, content));
var result1 = await ExecuteAsync(new ExecutionOptions { Query = query });
var result2 = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new
{
data = new
{
findMySchemaContent = new
{
id = content.Id,
flatData = new
{
myReferences = new[]
{
new
{
id = contentRefId
}
}
}
}
}
};
AssertResult(expected, result1);
AssertResult(expected, result2);
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(),
A<Q>.That.HasIdsWithoutTotal(contentRefId), A<CancellationToken>._))
.MustHaveHappenedOnceExactly();
}
[Fact] [Fact]
public async Task Should_also_fetch_referencing_contents_if_field_is_included_in_query() public async Task Should_also_fetch_referencing_contents_if_field_is_included_in_query()
{ {

20
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -44,6 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
protected readonly IUserResolver userResolver = A.Fake<IUserResolver>(); protected readonly IUserResolver userResolver = A.Fake<IUserResolver>();
protected readonly Context requestContext; protected readonly Context requestContext;
private CachingGraphQLResolver sut;
public GraphQLTestBase() public GraphQLTestBase()
{ {
@ -84,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private async Task<ExecutionResult> ExcecuteAsync(ExecutionOptions options, Context context) private async Task<ExecutionResult> ExcecuteAsync(ExecutionOptions options, Context context)
{ {
var sut = CreateSut(TestSchemas.Default, TestSchemas.Ref1, TestSchemas.Ref2); sut ??= CreateSut(TestSchemas.Default, TestSchemas.Ref1, TestSchemas.Ref2);
options.UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(sut.Services, context)!; options.UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(sut.Services, context)!;
@ -111,10 +112,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
new ServiceCollection() new ServiceCollection()
.AddMemoryCache() .AddMemoryCache()
.AddTransient<GraphQLExecutionContext>() .AddTransient<GraphQLExecutionContext>()
.Configure<AssetOptions>(x =>
{
x.CanCache = true;
})
.Configure<ContentOptions>(x =>
{
x.CanCache = true;
})
.AddSingleton<IDocumentExecutionListener, .AddSingleton<IDocumentExecutionListener,
DataLoaderDocumentListener>() DataLoaderDocumentListener>()
.AddSingleton<IDataLoaderContextAccessor, .AddSingleton<IDataLoaderContextAccessor,
DataLoaderContextAccessor>() DataLoaderContextAccessor>()
.AddTransient<IAssetCache,
AssetCache>()
.AddTransient<IContentCache,
ContentCache>()
.AddSingleton<IUrlGenerator,
FakeUrlGenerator>()
.AddSingleton(A.Fake<ILoggerFactory>()) .AddSingleton(A.Fake<ILoggerFactory>())
.AddSingleton(appProvider) .AddSingleton(appProvider)
.AddSingleton(assetQuery) .AddSingleton(assetQuery)
@ -124,9 +139,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.AddSingleton<InstantGraphType>() .AddSingleton<InstantGraphType>()
.AddSingleton<JsonGraphType>() .AddSingleton<JsonGraphType>()
.AddSingleton<JsonNoopGraphType>() .AddSingleton<JsonNoopGraphType>()
.AddSingleton<SharedTypes>()
.AddSingleton<IUrlGenerator,
FakeUrlGenerator>()
.BuildServiceProvider(); .BuildServiceProvider();
var schemasHash = A.Fake<ISchemasHash>(); var schemasHash = A.Fake<ISchemasHash>();

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

@ -294,7 +294,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.AddField("ref2", .AddField("ref2",
new ContentFieldData() new ContentFieldData()
.AddInvariant(JsonValue.Array(ref2.Select(x => x.ToString())))), .AddInvariant(JsonValue.Array(ref2.Select(x => x.ToString())))),
SchemaId = schemaId, AppId = appId, SchemaId = schemaId,
AppId = appId,
Version = 0 Version = 0
}; };
} }
@ -312,7 +313,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.AddField("number", .AddField("number",
new ContentFieldData() new ContentFieldData()
.AddInvariant(number)), .AddInvariant(number)),
SchemaId = refSchemaId, AppId = appId, SchemaId = refSchemaId,
AppId = appId,
Version = version Version = version
}; };
} }

180
backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs

@ -0,0 +1,180 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Xunit;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Infrastructure.Caching
{
public class QueryCacheTests
{
private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private record CachedEntry(int Value) : IWithId<int>
{
public int Id => Value;
}
[Fact]
public async Task Should_query_from_cache()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried, result) = await ConfigureAsync(sut, 1, 2);
Assert.Equal(new[] { 1, 2 }, queried);
Assert.Equal(new[] { 1, 2 }, result);
}
[Fact]
public async Task Should_query_pending_from_cache()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried1, result1) = await ConfigureAsync(sut, 1, 2);
var (queried2, result2) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, result1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, result2.ToArray());
}
[Fact]
public async Task Should_query_pending_from_cache_if_manually_added()
{
var sut = new QueryCache<int, CachedEntry>();
sut.SetMany(new[] { (1, null), (2, new CachedEntry(2)) });
var (queried, result) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 3, 4 }, queried);
Assert.Equal(new[] { 2, 3, 4 }, result);
}
[Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10);
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) }, cacheDuration);
var (queried, result) = await ConfigureAsync(sut2, x => true, cacheDuration, 1, 2, 3, 4);
Assert.Equal(new[] { 3, 4 }, queried);
Assert.Equal(new[] { 2, 3, 4 }, result);
}
[Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added_but_not_added_permanently()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) });
var (queried, result) = await ConfigureAsync(sut2, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2, 3, 4 }, queried);
Assert.Equal(new[] { 1, 2, 3, 4 }, result);
}
[Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added_but_not_queried_permanently()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10);
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) }, cacheDuration);
var (queried, result) = await ConfigureAsync(sut2, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2, 3, 4 }, queried);
Assert.Equal(new[] { 1, 2, 3, 4 }, result);
}
[Fact]
public async Task Should_not_query_again_if_failed_before()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried1, result1) = await ConfigureAsync(sut, x => x > 1, default, 1, 2);
var (queried2, result2) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 2 }, result1.ToArray());
Assert.Equal(new[] { 2, 3, 4 }, result2.ToArray());
}
[Fact]
public async Task Should_query_from_memory_cache()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10);
var (queried1, result1) = await ConfigureAsync(sut1, x => true, cacheDuration, 1, 2);
var (queried2, result2) = await ConfigureAsync(sut2, x => true, cacheDuration, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, result1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, result2.ToArray());
}
[Fact]
public async Task Should_not_query_from_memory_cache_if_not_queried_permanently()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var (queried1, result1) = await ConfigureAsync(sut1, x => true, null, 1, 2);
var (queried2, result2) = await ConfigureAsync(sut2, x => true, null, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, result1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, result2.ToArray());
}
private static Task<(int[], int[])> ConfigureAsync(IQueryCache<int, CachedEntry> sut, params int[] ids)
{
return ConfigureAsync(sut, x => true, null, ids);
}
private static async Task<(int[], int[])> ConfigureAsync(IQueryCache<int, CachedEntry> sut, Func<int, bool> predicate, TimeSpan? cacheDuration, params int[] ids)
{
var queried = new HashSet<int>();
var result = await sut.CacheOrQueryAsync(ids, async pending =>
{
queried.AddRange(pending);
await Task.Yield();
return pending.Where(predicate).Select(x => new CachedEntry(x));
}, cacheDuration);
return (queried.ToArray(), result.Select(x => x.Value).ToArray());
}
}
}
Loading…
Cancel
Save