From 4f8d6d091b5a04962fc83ee4cb2c0e9e775b1ea3 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 21 Feb 2022 20:55:22 +0100 Subject: [PATCH] Graphql memory cache. (#851) * Graphql memory cache. * Mini improvement. * Fix registrations. * Simplification. * Just some reformatting. --- .../Actions/Kafka/KafkaProducerOptions.cs | 2 +- .../ElasticSearchIndexDefinition.cs | 2 +- .../EnrichedEvents/IEnrichedEntityEvent.cs | 3 +- .../Schemas/ArrayField.cs | 2 +- .../HandleRules/EventEnricher.cs | 2 +- .../Contents/Operations/QueryByQuery.cs | 2 +- .../Rules/MongoRuleEventEntity.cs | 2 +- .../Invitation/InvitationEventConsumer.cs | 2 +- .../Assets/AssetCache.cs | 22 +++ .../Assets/AssetOptions.cs | 2 + .../Assets/AssetsFluidExtension.cs | 1 + .../Assets/IAssetCache.cs | 16 ++ .../Backup/IBackupJob.cs | 4 +- .../Contents/ContentCache.cs | 22 +++ .../Contents/ContentOptions.cs | 2 + .../GraphQL/CachingGraphQLResolver.cs | 6 +- .../GraphQL/GraphQLExecutionContext.cs | 63 ++++-- .../GraphQL/Types/AppQueriesGraphType.cs | 6 +- .../GraphQL/Types/Assets/AssetGraphType.cs | 17 +- .../Contents/GraphQL/Types/Builder.cs | 8 +- .../Types/Contents/ComponentGraphType.cs | 2 +- .../Types/Contents/ContentGraphType.cs | 2 +- .../GraphQL/Types/Contents/FieldVisitor.cs | 10 +- .../Types/Directives/CacheDirective.cs | 27 +++ .../{Extensions.cs => SharedExtensions.cs} | 20 +- .../Contents/GraphQL/Types/SharedTypes.cs | 116 ++++------- .../Contents/IContentCache.cs | 16 ++ .../Contents/Queries/QueryExecutionContext.cs | 81 ++++---- .../Contents/ReferencesFluidExtension.cs | 1 + .../Squidex.Domain.Apps.Entities/IEntity.cs | 6 +- .../DomainObject/Guards/GuardSchema.cs | 2 +- .../Caching/IQueryCache.cs | 18 ++ .../Caching/QueryCache.cs | 94 +++++++++ backend/src/Squidex.Infrastructure/IWithId.cs | 14 ++ .../Queries/Json/QueryParser.cs | 2 +- .../GraphQL/BufferingDocumentWriter.cs | 2 +- .../Rules/Models/RuleActionProcessor.cs | 3 +- .../Config/Authentication/OidcHandler.cs | 2 +- .../Squidex/Config/Domain/AssetServices.cs | 3 + .../Squidex/Config/Domain/ContentsServices.cs | 3 + .../Squidex/Config/Domain/QueryServices.cs | 3 - backend/src/Squidex/appsettings.json | 10 + .../DefaultValues/DefaultValuesTests.cs | 2 +- .../Apps/BackupAppsTests.cs | 2 +- .../CommentsCommandMiddlewareTests.cs | 3 +- .../ContentChangedTriggerHandlerTests.cs | 3 +- .../Contents/GraphQL/GraphQLQueriesTests.cs | 124 +++++++++++- .../Contents/GraphQL/GraphQLTestBase.cs | 20 +- .../Queries/ResolveReferencesTests.cs | 6 +- .../Contents/Text/TextIndexerTestsBase.cs | 2 +- .../Rules/RuleDequeuerGrainTests.cs | 12 +- .../Caching/QueryCacheTests.cs | 180 ++++++++++++++++++ 52 files changed, 760 insertions(+), 217 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCache.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCache.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/ContentCache.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs rename backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/{Extensions.cs => SharedExtensions.cs} (86%) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/IContentCache.cs create mode 100644 backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs create mode 100644 backend/src/Squidex.Infrastructure/Caching/QueryCache.cs create mode 100644 backend/src/Squidex.Infrastructure/IWithId.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs diff --git a/backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs b/backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs index 6a85799d2..36b81de0d 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs @@ -13,7 +13,7 @@ namespace Squidex.Extensions.Actions.Kafka { public class KafkaProducerOptions : ProducerConfig { - public SchemaRegistryConfig SchemaRegistry { get; set; } + public SchemaRegistryConfig SchemaRegistry { get; set; } public AvroSerializerConfig AvroSerializer { get; set; } diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs index abfdd6120..020c373c0 100644 --- a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs @@ -105,7 +105,7 @@ namespace Squidex.Extensions.Text.ElasticSearch { ["geoObject"] = new { - type ="geo_point" + type = "geo_point" } } }; diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/IEnrichedEntityEvent.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/IEnrichedEntityEvent.cs index d3b08b379..452aec1c6 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/IEnrichedEntityEvent.cs +++ b/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 { - public interface IEnrichedEntityEvent + public interface IEnrichedEntityEvent : IWithId { - DomainId Id { get; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs index cc3e2b8bf..5ce58377c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs @@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Core.Schemas get => FieldCollection.ByName; } - public FieldCollection FieldCollection { get; private set; } = FieldCollection.Empty; + public FieldCollection FieldCollection { get; private set; } = FieldCollection.Empty; public ArrayField(long id, string name, Partitioning partitioning, NestedField[] fields, ArrayFieldProperties? properties = null, IFieldSettings? settings = null) : base(id, name, partitioning, properties, settings) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs index fe0adac40..b180dec43 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules IUser? user; try { - user = await userResolver.FindByIdAsync(actor.Identifier); + user = await userResolver.FindByIdAsync(actor.Identifier); } catch { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index 309150f75..db95850e8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs @@ -224,7 +224,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return Filter.And(filters); } - private static FilterDefinition CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, + private static FilterDefinition CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, DomainId referenced, RefToken? createdBy) { var filters = new List> diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs index 6daccd47c..65d29fb16 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventEntity.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules [BsonElement] public Instant? NextAttempt { get; set; } - DomainId IEntity.Id + DomainId IWithId.Id { get => JobId; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs index e3ca46129..404133472 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs @@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation public string EventsFilter { - get { return "^app-"; } + get { return "^app-"; } } public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCache.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCache.cs new file mode 100644 index 000000000..1abde32b9 --- /dev/null +++ b/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, IAssetCache + { + public AssetCache(IMemoryCache? memoryCache, IOptions options) + : base(options.Value.CanCache ? memoryCache : null) + { + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs index fe22f495a..4259ec309 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetOptions.cs +++ b/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 CanCache { get; set; } + public int DefaultPageSize { get; set; } = 200; public int MaxResults { get; set; } = 200; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs index 85b82e08e..a3cce8b5e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs @@ -67,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { memberAccessStrategy.Register(); memberAccessStrategy.Register(); + memberAccessStrategy.Register>(); memberAccessStrategy.Register(); memberAccessStrategy.Register(); memberAccessStrategy.Register(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCache.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCache.cs new file mode 100644 index 000000000..b3875376f --- /dev/null +++ b/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 + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs index 3dbccbabe..1cc4df501 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs @@ -10,10 +10,8 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Backup { - public interface IBackupJob + public interface IBackupJob : IWithId { - DomainId Id { get; } - Instant Started { get; } Instant? Stopped { get; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentCache.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentCache.cs new file mode 100644 index 000000000..945795b1a --- /dev/null +++ b/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, IContentCache + { + public ContentCache(IMemoryCache? memoryCache, IOptions options) + : base(options.Value.CanCache ? memoryCache : null) + { + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs index 1c9e926fd..1c18c1029 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentOptions { + public bool CanCache { get; set; } + public int DefaultPageSize { get; set; } = 200; public int MaxResults { get; set; } = 200; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs index 591e7a547..e467f5abc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs +++ b/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 IServiceProvider serviceProvider; private readonly GraphQLOptions options; - private readonly SharedTypes sharedTypes; 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.serviceProvider = serviceProvider; this.options = options.Value; - - sharedTypes = serviceProvider.GetRequiredService(); } public async Task ConfigureAsync(ExecutionOptions executionOptions) @@ -91,8 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var hash = await schemasHash.ComputeHashAsync(app, schemas); - return new CacheEntry(new Builder(app, sharedTypes).BuildSchema(schemas), - hash, SystemClock.Instance.GetCurrentInstant()); + return new CacheEntry(new Builder(app).BuildSchema(schemas), hash, SystemClock.Instance.GetCurrentInstant()); } private static object CreateCacheKey(DomainId appId, string etag) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 2a8c468f3..49c9b2834 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/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 GraphQLExecutionContext(IServiceProvider serviceProvider, IDataLoaderContextAccessor dataLoaders, Context context) - : base(serviceProvider) + public GraphQLExecutionContext( + IDataLoaderContextAccessor dataLoaders, + IAssetQueryService assetQuery, + IAssetCache assetCache, + IContentQueryService contentQuery, + IContentCache contentCache, + IServiceProvider serviceProvider, + Context context) + : base(assetQuery, assetCache, contentQuery, contentCache, serviceProvider) { this.dataLoaders = dataLoaders; @@ -70,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return content; } - public async Task> GetReferencedAssetsAsync(IJsonValue value, + public async Task> GetReferencedAssetsAsync(IJsonValue value, TimeSpan cacheDuration, CancellationToken ct) { var ids = ParseIds(value); @@ -80,14 +87,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return EmptyAssets; } - var dataLoader = GetAssetsLoader(); + async Task> LoadAsync(IEnumerable ids) + { + var result = await GetAssetsLoader().LoadAsync(ids).GetResultAsync(ct); + + return result?.NotNull().ToList() ?? EmptyAssets; + } - var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); + if (cacheDuration > TimeSpan.Zero) + { + var assets = await AssetCache.CacheOrQueryAsync(ids, async pendingIds => + { + return await LoadAsync(pendingIds); + }, cacheDuration); + + return assets; + } - return result?.NotNull().ToList() ?? EmptyAssets; + return await LoadAsync(ids); } - public async Task> GetReferencedContentsAsync(IJsonValue value, + public async Task> GetReferencedContentsAsync(IJsonValue value, TimeSpan cacheDuration, CancellationToken ct) { var ids = ParseIds(value); @@ -97,11 +117,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return EmptyContents; } - var dataLoader = GetContentsLoader(); + async Task> LoadAsync(IEnumerable ids) + { + var result = await GetContentsLoader().LoadAsync(ids).GetResultAsync(ct); + + return result?.NotNull().ToList() ?? EmptyContents; + } - var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); + if (cacheDuration > TimeSpan.Zero) + { + var contents = await ContentCache.CacheOrQueryAsync(ids, async pendingIds => + { + return await LoadAsync(pendingIds); + }, cacheDuration); + + return contents.ToList(); + } - return result?.NotNull().ToList() ?? EmptyContents; + return await LoadAsync(ids); } private IDataLoader GetAssetsLoader() @@ -137,17 +170,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL }); } - private static ICollection? ParseIds(IJsonValue value) + private static List? ParseIds(IJsonValue value) { try { - var result = new List(); + List? result = null; if (value is JsonArray array) { foreach (var id in array) { - result.Add(DomainId.Create(id.ToString())); + if (id is JsonString jsonString) + { + result ??= new List(); + result.Add(DomainId.Create(jsonString.Value)); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs index 61b1fa6e1..140fe7ae4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/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 schemaInfos) { - AddField(builder.SharedTypes.FindAsset); - AddField(builder.SharedTypes.QueryAssets); - AddField(builder.SharedTypes.QueryAssetsWithTotal); + AddField(SharedTypes.FindAsset); + AddField(SharedTypes.QueryAssets); + AddField(SharedTypes.QueryAssetsWithTotal); foreach (var schemaInfo in schemaInfos) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs index 88ce45063..c6a185fe3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs +++ b/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 { - public AssetGraphType(bool canGenerateSourceUrl) + public AssetGraphType() { // The name is used for equal comparison. Therefore it is important to treat it as readonly. Name = "Asset"; @@ -235,16 +235,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets Description = FieldDescriptions.EditToken }); - if (canGenerateSourceUrl) + AddField(new FieldType { - AddField(new FieldType - { - Name = "sourceUrl", - ResolvedType = AllTypes.NonNullString, - Resolver = SourceUrl, - Description = FieldDescriptions.AssetSourceUrl - }); - } + Name = "sourceUrl", + ResolvedType = AllTypes.NonNullString, + Resolver = SourceUrl, + Description = FieldDescriptions.AssetSourceUrl + }); Description = "An asset"; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs index a2a996423..0eff96f84 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs @@ -30,18 +30,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private readonly PartitionResolver partitionResolver; private readonly List allSchemas = new List(); - public SharedTypes SharedTypes { get; } - static Builder() { ValueConverter.Register(DomainId.Create); ValueConverter.Register(x => new Status(x)); } - public Builder(IAppEntity app, SharedTypes sharedTypes) + public Builder(IAppEntity app) { - SharedTypes = sharedTypes; - partitionResolver = app.PartitionResolver(); fieldVisitor = new FieldVisitor(this); @@ -79,6 +75,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types newSchema.RegisterType(SharedTypes.ComponentInterface); newSchema.RegisterType(SharedTypes.ContentInterface); + newSchema.Directives.Register(SharedTypes.MemoryCacheDirective); + if (schemaInfos.Any()) { var mutations = new AppMutationsGraphType(this, schemaInfos); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentGraphType.cs index 27137c318..017eb2052 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentGraphType.cs +++ b/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 CheckType(string schemaId) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs index 0d3b6cda0..b9a0b91c3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs @@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents AddReferencesQueries(builder, other); } - AddResolvedInterface(builder.SharedTypes.ContentInterface); + AddResolvedInterface(SharedTypes.ContentInterface); Description = $"The structure of a {schemaInfo.DisplayName} content type."; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs index b1e999b06..84cb8026a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs @@ -81,12 +81,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents 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) => { - return context.GetReferencedContentsAsync(value, fieldContext.CancellationToken); + var cacheDuration = fieldContext.CacheDuration(); + + return context.GetReferencedContentsAsync(value, cacheDuration, fieldContext.CancellationToken); }); private readonly Builder builder; @@ -115,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) { - return (builder.SharedTypes.AssetsList, Assets, null); + return (SharedTypes.AssetsList, Assets, null); } public (IGraphType?, IFieldResolver?, QueryArguments?) Visit(IField field, FieldInfo args) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Directives/CacheDirective.cs new file mode 100644 index 000000000..1b2d5dced --- /dev/null +++ b/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 + { + Name = "duration", + Description = "Cache duration in seconds.", + DefaultValue = 600 + }); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs similarity index 86% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs index 15843fadc..2b50ce5c3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedExtensions.cs @@ -6,6 +6,7 @@ // ========================================================================== using GraphQL; +using GraphQL.Language.AST; using GraphQL.Types; using GraphQL.Utilities; 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 { - public static class Extensions + public static class SharedExtensions { internal static string BuildODataQuery(this IResolveFieldContext context) { @@ -123,5 +124,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types 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; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs index cd528de0c..07aab8f21 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs @@ -6,101 +6,51 @@ // ========================================================================== 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.Contents; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class SharedTypes + public static class SharedTypes { - private readonly Lazy asset; - private readonly Lazy assetsList; - private readonly Lazy assetsResult; - private readonly Lazy contentInterface; - private readonly Lazy componentInterface; - private readonly Lazy findAsset; - private readonly Lazy queryAssets; - private readonly Lazy queryAssetsWithTotal; + public static readonly IGraphType Asset = new AssetGraphType(); - 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 FieldType QueryAssets => queryAssets.Value; - - public FieldType QueryAssetsWithTotal => queryAssetsWithTotal.Value; - - public SharedTypes(IUrlGenerator urlGenerator) + public static readonly FieldType FindAsset = new FieldType { - asset = new Lazy(() => - { - return new AssetGraphType(urlGenerator.CanGenerateAssetSourceUrl); - }); - - assetsList = new Lazy(() => - { - return new ListGraphType(new NonNullGraphType(Asset)); - }); - - assetsResult = new Lazy(() => - { - return new AssetsResultGraphType(AssetsList); - }); - - contentInterface = new Lazy(() => - { - return new ContentInterfaceGraphType(); - }); - - componentInterface = new Lazy(() => - { - return new ComponentInterfaceGraphType(); - }); - - findAsset = new Lazy(() => - { - return new FieldType - { - Name = "findAsset", - Arguments = AssetActions.Find.Arguments, - ResolvedType = Asset, - Resolver = AssetActions.Find.Resolver, - Description = "Find an asset by id." - }; - }); - - queryAssets = new Lazy(() => - { - return new FieldType - { - Name = "queryAssets", - Arguments = AssetActions.Query.Arguments, - ResolvedType = AssetsList, - Resolver = AssetActions.Query.Resolver, - Description = "Get assets." - }; - }); - - queryAssetsWithTotal = new Lazy(() => - { - return new FieldType - { - Name = "queryAssetsWithTotal", - Arguments = AssetActions.Query.Arguments, - ResolvedType = AssetsResult, - Resolver = AssetActions.Query.ResolverWithTotal, - Description = "Get assets and total count." - }; - }); - } + Name = "findAsset", + Arguments = AssetActions.Find.Arguments, + ResolvedType = Asset, + Resolver = AssetActions.Find.Resolver, + Description = "Find an asset by id." + }; + + public static readonly FieldType QueryAssets = new FieldType + { + Name = "queryAssets", + Arguments = AssetActions.Query.Arguments, + ResolvedType = AssetsList, + Resolver = AssetActions.Query.Resolver, + Description = "Get assets." + }; + + public static readonly FieldType QueryAssetsWithTotal = new FieldType + { + Name = "queryAssetsWithTotal", + Arguments = AssetActions.Query.Arguments, + ResolvedType = AssetsResult, + Resolver = AssetActions.Query.ResolverWithTotal, + Description = "Get assets and total count." + }; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentCache.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentCache.cs new file mode 100644 index 000000000..7e5085816 --- /dev/null +++ b/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 + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 1959e9695..3b8709881 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; @@ -15,24 +14,40 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public abstract class QueryExecutionContext : Dictionary { private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); - private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); - private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); 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; } - protected QueryExecutionContext(IServiceProvider serviceProvider) + protected QueryExecutionContext( + IAssetQueryService assetQuery, + IAssetCache assetCache, + IContentQueryService contentQuery, + IContentCache contentCache, + IServiceProvider serviceProvider) { Guard.NotNull(serviceProvider); + AssetQuery = assetQuery; + AssetCache = assetCache; + ContentQuery = contentQuery; + ContentCache = contentCache; + Services = serviceProvider; } public virtual Task FindContentAsync(string schemaIdOrName, DomainId id, long version, CancellationToken ct) { - return Resolve().FindAsync(Context, schemaIdOrName, id, version, ct); + return ContentQuery.FindAsync(Context, schemaIdOrName, id, version, ct); } public virtual async Task> QueryAssetsAsync(Q q, @@ -43,17 +58,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - assets = await Resolve().QueryAsync(Context, null, q, ct); + assets = await AssetQuery.QueryAsync(Context, null, q, ct); } finally { maxRequests.Release(); } - foreach (var asset in assets) - { - cachedAssets[asset.Id] = asset; - } + AssetCache.SetMany(assets.Select(x => (x.Id, x))!); return assets; } @@ -66,83 +78,58 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - contents = await Resolve().QueryAsync(Context, schemaIdOrName, q, ct); + contents = await ContentQuery.QueryAsync(Context, schemaIdOrName, q, ct); } finally { maxRequests.Release(); } - foreach (var content in contents) - { - cachedContents[content.Id] = content; - } + ContentCache.SetMany(contents.Select(x => (x.Id, x))!); return contents; } - public virtual async Task> GetReferencedAssetsAsync(ICollection ids, + public virtual async Task> GetReferencedAssetsAsync(IEnumerable ids, CancellationToken ct) { Guard.NotNull(ids); - var notLoadedAssets = new HashSet(ids.Where(id => !cachedAssets.ContainsKey(id))); - - if (notLoadedAssets.Count > 0) + return await AssetCache.CacheOrQueryAsync(ids, async pendingIds => { - IResultList assets; - await maxRequests.WaitAsync(ct); try { - var q = Q.Empty.WithIds(notLoadedAssets).WithoutTotal(); + var q = Q.Empty.WithIds(pendingIds).WithoutTotal(); - assets = await Resolve().QueryAsync(Context, null, q, ct); + return await AssetQuery.QueryAsync(Context, null, q, ct); } finally { maxRequests.Release(); } - - foreach (var asset in assets) - { - cachedAssets[asset.Id] = asset; - } - } - - return ids.Select(cachedAssets.GetOrDefault).NotNull().ToList(); + }); } - public virtual async Task> GetReferencedContentsAsync(ICollection ids, + public virtual async Task> GetReferencedContentsAsync(IEnumerable ids, CancellationToken ct) { Guard.NotNull(ids); - var notLoadedContents = ids.Where(id => !cachedContents.ContainsKey(id)).ToList(); - - if (notLoadedContents.Count > 0) + return await ContentCache.CacheOrQueryAsync(ids, async pendingIds => { - IResultList contents; - await maxRequests.WaitAsync(ct); try { - var q = Q.Empty.WithIds(notLoadedContents).WithoutTotal(); + var q = Q.Empty.WithIds(pendingIds).WithoutTotal(); - contents = await Resolve().QueryAsync(Context, q, ct); + return await ContentQuery.QueryAsync(Context, q, ct); } finally { maxRequests.Release(); } - - foreach (var content in contents) - { - cachedContents[content.Id] = content; - } - } - - return ids.Select(cachedContents.GetOrDefault).NotNull().ToList(); + }); } public T Resolve() where T : class diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs index c6e74be1c..f54f6bd40 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -62,6 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) { memberAccessStrategy.Register(); + memberAccessStrategy.Register>(); memberAccessStrategy.Register(); memberAccessStrategy.Register(); memberAccessStrategy.Register(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs index 2e60b370c..62d7de0ec 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IEntity.cs @@ -10,14 +10,12 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities { - public interface IEntity + public interface IEntity : IWithId { - DomainId Id { get; } - Instant Created { get; } Instant LastModified { get; } DomainId UniqueId { get; } } -} \ No newline at end of file +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs index 63817260f..81f6e5558 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs @@ -199,7 +199,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards if (field.Properties == null) { - e(Not.Defined(nameof(field.Properties)), $"{prefix}.{nameof(field.Properties)}"); + e(Not.Defined(nameof(field.Properties)), $"{prefix}.{nameof(field.Properties)}"); } else { diff --git a/backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs b/backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs new file mode 100644 index 000000000..fdbd445e8 --- /dev/null +++ b/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 where TKey : notnull where T : class, IWithId + { + void SetMany(IEnumerable<(TKey, T?)> results, + TimeSpan? permanentDuration = null); + + Task> CacheOrQueryAsync(IEnumerable keys, Func, Task>> query, + TimeSpan? permanentDuration = null); + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/QueryCache.cs b/backend/src/Squidex.Infrastructure/Caching/QueryCache.cs new file mode 100644 index 000000000..f96eccf19 --- /dev/null +++ b/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 : IQueryCache where TKey : notnull where T : class, IWithId + { + private readonly ConcurrentDictionary entries = new ConcurrentDictionary(); + 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> CacheOrQueryAsync(IEnumerable keys, Func, Task>> query, + TimeSpan? permanentDuration = null) + { + Guard.NotNull(keys); + Guard.NotNull(query); + + var items = GetMany(keys, permanentDuration.HasValue); + + var pendingIds = new HashSet(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 GetMany(IEnumerable keys, + bool fromPermanentCache = false) + { + var result = new Dictionary(); + + 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; + } + } +} diff --git a/backend/src/Squidex.Infrastructure/IWithId.cs b/backend/src/Squidex.Infrastructure/IWithId.cs new file mode 100644 index 000000000..fe5b7de36 --- /dev/null +++ b/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 Id { get; } + } +} diff --git a/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs b/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs index e78d74d62..16707c9b4 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs @@ -80,7 +80,7 @@ namespace Squidex.Infrastructure.Queries.Json { try { - return jsonSerializer.Deserialize>(json); + return jsonSerializer.Deserialize>(json); } catch (JsonException ex) { diff --git a/backend/src/Squidex.Web/GraphQL/BufferingDocumentWriter.cs b/backend/src/Squidex.Web/GraphQL/BufferingDocumentWriter.cs index 7173ea0d9..1ec945504 100644 --- a/backend/src/Squidex.Web/GraphQL/BufferingDocumentWriter.cs +++ b/backend/src/Squidex.Web/GraphQL/BufferingDocumentWriter.cs @@ -21,7 +21,7 @@ namespace Squidex.Web.GraphQL documentWriter = new DocumentWriter(action); } - public async Task WriteAsync(Stream stream, T value, + public async Task WriteAsync(Stream stream, T value, CancellationToken cancellationToken = default) { await using (var bufferStream = new FileBufferingWriteStream()) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs index cd983bb21..794c0b813 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs +++ b/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 { - JsonInheritanceConverter = new RuleActionConverter(), PropertyName = "actionType" + JsonInheritanceConverter = new RuleActionConverter(), + PropertyName = "actionType" }; schema.Properties["actionType"] = new JsonSchemaProperty diff --git a/backend/src/Squidex/Config/Authentication/OidcHandler.cs b/backend/src/Squidex/Config/Authentication/OidcHandler.cs index 216768305..19ba1bfab 100644 --- a/backend/src/Squidex/Config/Authentication/OidcHandler.cs +++ b/backend/src/Squidex/Config/Authentication/OidcHandler.cs @@ -15,7 +15,7 @@ namespace Squidex.Config.Authentication { private readonly MyIdentityOptions options; - public OidcHandler(MyIdentityOptions options ) + public OidcHandler(MyIdentityOptions options) { this.options = options; } diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index 6746ed0c3..a5c144374 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -54,6 +54,9 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); + services.AddTransientAs() + .As(); + services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Config/Domain/ContentsServices.cs b/backend/src/Squidex/Config/Domain/ContentsServices.cs index e15dbc83a..ca09bd1bd 100644 --- a/backend/src/Squidex/Config/Domain/ContentsServices.cs +++ b/backend/src/Squidex/Config/Domain/ContentsServices.cs @@ -40,6 +40,9 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); + services.AddTransientAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Config/Domain/QueryServices.cs b/backend/src/Squidex/Config/Domain/QueryServices.cs index ea5841b6a..ff54b3564 100644 --- a/backend/src/Squidex/Config/Domain/QueryServices.cs +++ b/backend/src/Squidex/Config/Domain/QueryServices.cs @@ -25,9 +25,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs(c => ActivatorUtilities.CreateInstance(c, exposeSourceUrl)) .As(); - services.AddSingletonAs() - .AsSelf(); - services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index e35b1cf3a..4b173e74f 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -212,6 +212,11 @@ }, "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. "defaultPageSize": 200, @@ -228,6 +233,11 @@ }, "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. "defaultPageSize": 200, diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs index 3542ac132..229848947 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs @@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Operations.DefaultValues { var field = Fields.Assets(1, "1", Partitioning.Invariant, - new AssetsFieldProperties { DefaultValue = ReadonlyList.Create("1", "2" ) }); + new AssetsFieldProperties { DefaultValue = ReadonlyList.Create("1", "2") }); Assert.Equal(JsonValue.Array("1", "2"), DefaultValueFactory.CreateDefaultValue(field, now, language.Iso2Code)); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs index afb9ae84c..6cad83687 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { ct = cts.Token; - sut = new BackupApps(rebuilder, appImageStore, appsIndex, appUISettings); + sut = new BackupApps(rebuilder, appImageStore, appsIndex, appUISettings); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs index dfd107c2e..b5f4a53f7 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs +++ b/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 { - Text = "Hi @invalid@squidex.io", IsMention = true + Text = "Hi @invalid@squidex.io", + IsMention = true }; var context = CrateCommandContext(command); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs index 0dbc158cd..74fce6725 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -360,7 +360,8 @@ namespace Squidex.Domain.Apps.Entities.Contents Schemas = ReadonlyList.Create( new ContentChangedTriggerSchemaV2 { - SchemaId = schemaId.Id, Condition = condition + SchemaId = schemaId.Id, + Condition = condition }) }; } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 098df2c3c..6c30e25d3 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -428,7 +428,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(0, contentRef)); @@ -472,6 +472,122 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL 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 + flatData { + myReferences { + id + } + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) + .Returns(ResultList.CreateFrom(0, contentRef)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .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 + flatData { + myReferences @cache(duration: 1000) { + id + } + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) + .Returns(ResultList.CreateFrom(0, contentRef)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .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.That.HasIdsWithoutTotal(contentRefId), A._)) + .MustHaveHappenedOnceExactly(); + } + [Fact] public async Task Should_also_fetch_referencing_contents_if_field_is_included_in_query() { @@ -496,7 +612,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentRefId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(1, contentRef)); @@ -561,7 +677,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentRefId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(1, contentRef)); @@ -675,7 +791,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index c3110f008..ca7899852 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -44,6 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL protected readonly IContentQueryService contentQuery = A.Fake(); protected readonly IUserResolver userResolver = A.Fake(); protected readonly Context requestContext; + private CachingGraphQLResolver sut; public GraphQLTestBase() { @@ -84,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private async Task 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(sut.Services, context)!; @@ -111,10 +112,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL new ServiceCollection() .AddMemoryCache() .AddTransient() + .Configure(x => + { + x.CanCache = true; + }) + .Configure(x => + { + x.CanCache = true; + }) .AddSingleton() .AddSingleton() + .AddTransient() + .AddTransient() + .AddSingleton() .AddSingleton(A.Fake()) .AddSingleton(appProvider) .AddSingleton(assetQuery) @@ -124,9 +139,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() .BuildServiceProvider(); var schemasHash = A.Fake(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs index 6ae343290..8c5aebeee 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs @@ -294,7 +294,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .AddField("ref2", new ContentFieldData() .AddInvariant(JsonValue.Array(ref2.Select(x => x.ToString())))), - SchemaId = schemaId, AppId = appId, + SchemaId = schemaId, + AppId = appId, Version = 0 }; } @@ -312,7 +313,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .AddField("number", new ContentFieldData() .AddInvariant(number)), - SchemaId = refSchemaId, AppId = appId, + SchemaId = refSchemaId, + AppId = appId, Version = version }; } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs index 05d7eb797..320a90fc0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs @@ -194,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text await SearchText(expected: ids1, text: "V3", target: SearchScope.All); await SearchText(expected: ids1, text: "V3", target: SearchScope.Published); - } + } [Fact] public async Task Should_simulate_new_version() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs index 910bc15e9..ea3db12c4 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerGrainTests.cs @@ -92,12 +92,12 @@ namespace Squidex.Domain.Apps.Entities.Rules } [Theory] - [InlineData(0, 0, RuleResult.Success, RuleJobResult.Success)] - [InlineData(0, 5, RuleResult.Timeout, RuleJobResult.Retry)] - [InlineData(1, 60, RuleResult.Timeout, RuleJobResult.Retry)] - [InlineData(2, 360, RuleResult.Failed, RuleJobResult.Retry)] - [InlineData(3, 720, RuleResult.Failed, RuleJobResult.Retry)] - [InlineData(4, 0, RuleResult.Failed, RuleJobResult.Failed)] + [InlineData(0, 0, RuleResult.Success, RuleJobResult.Success)] + [InlineData(0, 5, RuleResult.Timeout, RuleJobResult.Retry)] + [InlineData(1, 60, RuleResult.Timeout, RuleJobResult.Retry)] + [InlineData(2, 360, RuleResult.Failed, RuleJobResult.Retry)] + [InlineData(3, 720, RuleResult.Failed, RuleJobResult.Retry)] + [InlineData(4, 0, RuleResult.Failed, RuleJobResult.Failed)] public async Task Should_set_next_attempt_based_on_num_calls(int calls, int minutes, RuleResult result, RuleJobResult jobResult) { var actionData = "{}"; diff --git a/backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs new file mode 100644 index 000000000..d5e7879c2 --- /dev/null +++ b/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 + { + public int Id => Value; + } + + [Fact] + public async Task Should_query_from_cache() + { + var sut = new QueryCache(); + + 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(); + + 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(); + + 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(memoryCache); + var sut2 = new QueryCache(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(memoryCache); + var sut2 = new QueryCache(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(memoryCache); + var sut2 = new QueryCache(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(); + + 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(memoryCache); + var sut2 = new QueryCache(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(memoryCache); + var sut2 = new QueryCache(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 sut, params int[] ids) + { + return ConfigureAsync(sut, x => true, null, ids); + } + + private static async Task<(int[], int[])> ConfigureAsync(IQueryCache sut, Func predicate, TimeSpan? cacheDuration, params int[] ids) + { + var queried = new HashSet(); + + 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()); + } + } +}