diff --git a/src/VirtoCommerce.Xapi.Core/BaseQueries/CommandBuilder.cs b/src/VirtoCommerce.Xapi.Core/BaseQueries/CommandBuilder.cs index 0d83cfc..0de6213 100644 --- a/src/VirtoCommerce.Xapi.Core/BaseQueries/CommandBuilder.cs +++ b/src/VirtoCommerce.Xapi.Core/BaseQueries/CommandBuilder.cs @@ -17,8 +17,14 @@ public abstract class CommandBuilder where TCommand : IRequest where TResultGraphType : IGraphType { + protected CommandBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] protected CommandBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } @@ -60,7 +72,7 @@ public override void Build(ISchema schema) protected override IEnumerable GetArguments() { // no arguments needed for this type of command builder - return Array.Empty(); + return []; } protected override TCommand GetRequest(IResolveFieldContext context) diff --git a/src/VirtoCommerce.Xapi.Core/BaseQueries/QueryBuilder.cs b/src/VirtoCommerce.Xapi.Core/BaseQueries/QueryBuilder.cs index 1fc0be8..e0a84c6 100644 --- a/src/VirtoCommerce.Xapi.Core/BaseQueries/QueryBuilder.cs +++ b/src/VirtoCommerce.Xapi.Core/BaseQueries/QueryBuilder.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using GraphQL; using GraphQL.Types; @@ -13,8 +14,14 @@ public abstract class QueryBuilder where TQuery : IQuery, IExtendableQuery, IHasArguments where TResultGraphType : IGraphType { + protected QueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] protected QueryBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } diff --git a/src/VirtoCommerce.Xapi.Core/BaseQueries/RequestBuilder.cs b/src/VirtoCommerce.Xapi.Core/BaseQueries/RequestBuilder.cs index 4233b85..1212a10 100644 --- a/src/VirtoCommerce.Xapi.Core/BaseQueries/RequestBuilder.cs +++ b/src/VirtoCommerce.Xapi.Core/BaseQueries/RequestBuilder.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using GraphQL; @@ -16,17 +17,19 @@ public abstract class RequestBuilder : where TRequest : IRequest where TResponseGraphType : IGraphType { - private readonly IMediator _mediator; private readonly IAuthorizationService _authorizationService; protected abstract string Name { get; } - protected RequestBuilder( - IMediator mediator, - IAuthorizationService authorizationService) + protected RequestBuilder(IAuthorizationService authorizationService) { _authorizationService = authorizationService; - _mediator = mediator; + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] + protected RequestBuilder(IMediator mediator, IAuthorizationService authorizationService) + : this(authorizationService) + { } public abstract void Build(ISchema schema); @@ -38,6 +41,7 @@ protected virtual FieldType GetFieldType() .ResolveAsync(async context => { var (_, response) = await Resolve(context); + return response; }); @@ -83,7 +87,8 @@ protected virtual Task AfterMediatorSend(IResolveFieldContext context, T protected virtual async Task GetResponseAsync(IResolveFieldContext context, TRequest request) { - return await _mediator.Send(request); + // Not ctor-injected: builders are singletons, a ctor-captured mediator would be root-bound (see GetMediator docs). + return await context.GetMediator().Send(request); } protected virtual async Task Authorize(IResolveFieldContext context, object resource, IAuthorizationRequirement requirement) diff --git a/src/VirtoCommerce.Xapi.Core/BaseQueries/SearchQueryBuilder.cs b/src/VirtoCommerce.Xapi.Core/BaseQueries/SearchQueryBuilder.cs index 3f3c0b7..375d988 100644 --- a/src/VirtoCommerce.Xapi.Core/BaseQueries/SearchQueryBuilder.cs +++ b/src/VirtoCommerce.Xapi.Core/BaseQueries/SearchQueryBuilder.cs @@ -1,3 +1,4 @@ +using System; using GraphQL.Types; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -16,8 +17,14 @@ public abstract class SearchQueryBuilder { protected virtual int DefaultPageSize => Connections.DefaultPageSize; + protected SearchQueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] protected SearchQueryBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } @@ -31,6 +38,7 @@ protected override FieldType GetFieldType() builder.ResolveAsync(async context => { var (query, response) = await Resolve(context); + return new PagedConnection(response.Results, query.Skip, query.Take, response.TotalCount); }); diff --git a/src/VirtoCommerce.Xapi.Core/Extensions/RequestScopedCacheExtensions.cs b/src/VirtoCommerce.Xapi.Core/Extensions/RequestScopedCacheExtensions.cs new file mode 100644 index 0000000..186e2dd --- /dev/null +++ b/src/VirtoCommerce.Xapi.Core/Extensions/RequestScopedCacheExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Xapi.Core.Services; + +namespace VirtoCommerce.Xapi.Core.Extensions; + +public static class RequestScopedCacheExtensions +{ + /// + /// By-id form for items keyed by : delegates to + /// . + /// + public static Task> GetOrLoadByIdsAsync( + this IRequestScopedCache cache, + string keyPrefix, + ICollection ids, + Func, Task>> loadMissing) + where T : class, IEntity + { + return cache.GetOrLoadByIdsAsync(keyPrefix, ids, static x => x.Id, loadMissing); + } +} diff --git a/src/VirtoCommerce.Xapi.Core/Extensions/ResolveFieldContextExtensions.cs b/src/VirtoCommerce.Xapi.Core/Extensions/ResolveFieldContextExtensions.cs index 0e783ec..690019d 100644 --- a/src/VirtoCommerce.Xapi.Core/Extensions/ResolveFieldContextExtensions.cs +++ b/src/VirtoCommerce.Xapi.Core/Extensions/ResolveFieldContextExtensions.cs @@ -4,6 +4,8 @@ using System.Security.Claims; using GraphQL; using GraphQL.Execution; +using MediatR; +using Microsoft.Extensions.DependencyInjection; using VirtoCommerce.CoreModule.Core.Currency; using VirtoCommerce.CustomerModule.Core.Extensions; using VirtoCommerce.Platform.Core.Common; @@ -15,168 +17,185 @@ namespace VirtoCommerce.Xapi.Core.Extensions { public static class ResolveFieldContextExtensions { - /// - /// Get value from user context - /// - /// Type of T - /// GraphQL UserContext - /// Search key - /// Default return if value not founded in UserContext - /// Return value of type from UserContext or - /// - public static T GetValue(this IResolveFieldContext resolveContext, string key, T defaultValue) - { - ArgumentNullException.ThrowIfNull(resolveContext); - - if (resolveContext.UserContext.TryGetValue(key, out var value)) + extension(IResolveFieldContext context) + { + /// + /// Resolves from the per-request DI scope (). + /// GraphQL types and schema builders are built once and act as singletons, so must never be + /// constructor-injected there: a mediator captured at construction time is bound to the root service provider, and any + /// Scoped dependency of a handler it dispatches to would fail to resolve (or be silently promoted to a singleton). + /// Call this from inside a resolver/DataLoader closure, which runs per request, never at construction time. + /// + public IMediator GetMediator() { - return CastValue(value, defaultValue); + return context.RequestServices?.GetRequiredService() + ?? throw new InvalidOperationException( + "Cannot resolve IMediator: IResolveFieldContext.RequestServices is null. " + + "Resolvers that dispatch requests require a request-scoped service provider (ExecutionOptions.RequestServices); " + + "the GraphQL HTTP middleware populates it - in tests, set RequestServices on the ResolveFieldContext explicitly."); } - return defaultValue; - - static T CastValue(object value, T defaultValue) + /// + /// Get value from user context + /// + /// Type of T + /// Search key + /// Default return if value not founded in UserContext + /// Return value of type from UserContext or + /// + public T GetValue(string key, T defaultValue) { - return value is ArgumentValue argumentValue ? (T)argumentValue.Value : CastValueAsTyped(value, defaultValue); + ArgumentNullException.ThrowIfNull(context); - static T CastValueAsTyped(object value, T defaultValue) + return context.UserContext.TryGetValue(key, out var value) + ? CastValue(value, defaultValue) + : defaultValue; + + static T CastValue(object value, T defaultValue) { - return value is T typedObject ? typedObject : defaultValue; + return value is ArgumentValue argumentValue ? (T)argumentValue.Value : CastValueAsTyped(value, defaultValue); + + static T CastValueAsTyped(object value, T defaultValue) + { + return value is T typedObject ? typedObject : defaultValue; + } } } - } - public static T GetValue(this IResolveFieldContext resolveContext, string key) - { - return resolveContext.GetValue(key, default(T)); - } + public T GetValue(string key) + { + return context.GetValue(key, default(T)); + } - public static T GetArgument(this IResolveFieldContext context, string name) where T : class - { - var type = GenericTypeHelper.GetActualType(); - var command = context.GetArgument(type, name) as T; - return command; - } + public T GetArgument(string name) where T : class + { + var type = GenericTypeHelper.GetActualType(); + var command = context.GetArgument(type, name) as T; - public static bool IsAuthenticated(this IResolveFieldContext resolveContext) - { - return resolveContext.GetCurrentPrincipal()?.Identity?.IsAuthenticated == true; - } + return command; + } - public static string GetCurrentUserId(this IResolveFieldContext resolveContext) - { - return resolveContext.GetCurrentPrincipal()?.GetCurrentUserId(); - } + public bool IsAuthenticated() + { + return context.GetCurrentPrincipal()?.Identity?.IsAuthenticated == true; + } - public static string GetCurrentOrganizationId(this IResolveFieldContext resolveContext) - { - return resolveContext.GetCurrentPrincipal()?.GetCurrentOrganizationId(); - } + public string GetCurrentUserId() + { + return context.GetCurrentPrincipal()?.GetCurrentUserId(); + } - public static ClaimsPrincipal GetCurrentPrincipal(this IResolveFieldContext resolveContext) - { - return ((GraphQLUserContext)resolveContext.UserContext).User; - } + public string GetCurrentOrganizationId() + { + return context.GetCurrentPrincipal()?.GetCurrentOrganizationId(); + } - public static T GetArgumentOrValue(this IResolveFieldContext resolveContext, string key) - { - return resolveContext.GetArgument(key) ?? resolveContext.GetValue(key); - } + public ClaimsPrincipal GetCurrentPrincipal() + { + return ((GraphQLUserContext)context.UserContext).User; + } - //PT-1606: Need to check what there is no any alternative way to access to the original request arguments in sub selection - public static void CopyArgumentsToUserContext(this IResolveFieldContext resolveContext) - { - if (!resolveContext.Arguments.IsNullOrEmpty()) + public T GetArgumentOrValue(string key) { - foreach (var pair in resolveContext.Arguments) - { - resolveContext.UserContext.TryAdd(pair.Key, pair.Value); - } + return context.GetArgument(key) ?? context.GetValue(key); } - // try to copy "command" variables from parent context - var commandVariables = resolveContext.Variables?.FirstOrDefault(x => x.Name == "command"); - if (commandVariables != null && commandVariables.Value is Dictionary variables) + //PT-1606: Need to check what there is no any alternative way to access to the original request arguments in sub selection + public void CopyArgumentsToUserContext() { - foreach (var pair in variables) + if (!context.Arguments.IsNullOrEmpty()) { - resolveContext.UserContext.TryAdd(pair.Key, pair.Value); + foreach (var pair in context.Arguments) + { + context.UserContext.TryAdd(pair.Key, pair.Value); + } } - } - } - - public static void SetExpandedObjectGraph(this IResolveFieldContext resolveContext, T value) - { - var entities = value.GetFlatObjectsListWithInterface(); - foreach (var key in entities.Where(x => !string.IsNullOrEmpty(x.Id))) - { - resolveContext.UserContext.TryAdd(key.Id, value); + // try to copy "command" variables from parent context + var commandVariables = context.Variables?.FirstOrDefault(x => x.Name == "command"); + if (commandVariables is { Value: Dictionary variables }) + { + foreach (var pair in variables) + { + context.UserContext.TryAdd(pair.Key, pair.Value); + } + } } - var valueObjects = value.GetFlatObjectsListWithInterface(); - foreach (var valueObject in valueObjects) + public void SetExpandedObjectGraph(T value) { - resolveContext.UserContext.TryAdd(((ValueObject)valueObject).GetCacheKey(), value); - } - } + var entities = value.GetFlatObjectsListWithInterface(); - public static TResult GetValueForSource(this IResolveFieldContext resolveContext) - { - ArgumentNullException.ThrowIfNull(resolveContext); + foreach (var entity in entities) + { + if (!string.IsNullOrEmpty(entity.Id)) + { + context.UserContext.TryAdd(entity.Id, value); + } + } - TResult result = default; + var valueObjects = value.GetFlatObjectsListWithInterface(); + foreach (var @object in valueObjects) + { + if (@object is ValueObject valueObject) + { + context.UserContext.TryAdd(valueObject.GetCacheKey(), value); + } + } + } - if (resolveContext.Source is IEntity entity) + public TResult GetValueForSource() { - result = resolveContext.GetValue(entity.Id); + ArgumentNullException.ThrowIfNull(context); + + var result = context.Source switch + { + IEntity entity => context.GetValue(entity.Id), + ValueObject valueObject => context.GetValue(valueObject.GetCacheKey()), + _ => default + }; + + return result; } - else if (resolveContext.Source is IValueObject valueObject) + + public void SetCurrencies(IEnumerable currencies, string cultureName) { - result = resolveContext.GetValue(((ValueObject)valueObject).GetCacheKey()); - } + ArgumentNullException.ThrowIfNull(currencies); - return result; - } + var currencyList = currencies as ICollection ?? currencies.ToList(); + var currenciesWithCulture = currencyList.Select(x => currencyList.GetCurrencyForLanguage(x.Code, cultureName)).ToArray(); - public static void SetCurrencies(this IResolveFieldContext context, IEnumerable currencies, string cultureName) - { - ArgumentNullException.ThrowIfNull(currencies); + context.UserContext["allCurrencies"] = currenciesWithCulture; + } - var currenciesWithCulture = currencies.Select(x => currencies.GetCurrencyForLanguage(x.Code, cultureName)).ToArray(); + public void SetCurrency(Currency currency) + { + ArgumentNullException.ThrowIfNull(currency); - context.UserContext["allCurrencies"] = currenciesWithCulture; - } + context.UserContext["currencyCode"] = currency.Code; + } - public static void SetCurrency(this IResolveFieldContext context, Currency currency) - { - ArgumentNullException.ThrowIfNull(currency); + public T GetDynamicPropertiesQuery() where T : IDynamicPropertiesQuery + { + var result = AbstractTypeFactory.TryCreateInstance(); + result.CultureName = context.GetCultureName(); - context.UserContext["currencyCode"] = currency.Code; - } + return result; + } - public static Currency GetCurrencyByCode(this IResolveFieldContext userContext, string currencyCode) - { - var allCurrencies = userContext.GetValue>("allCurrencies"); - var result = allCurrencies?.FirstOrDefault(x => x.Code.EqualsIgnoreCase(currencyCode)); - if (result == null) + public string GetCultureName() { - throw new OperationCanceledException($"the currency with code '{currencyCode}' is not registered"); + return context.GetArgumentOrValue(Constants.CultureName); } - - return result; } - public static T GetDynamicPropertiesQuery(this IResolveFieldContext context) where T : IDynamicPropertiesQuery + public static Currency GetCurrencyByCode(this IResolveFieldContext userContext, string currencyCode) { - var result = AbstractTypeFactory.TryCreateInstance(); - result.CultureName = context.GetCultureName(); - return result; - } + var allCurrencies = userContext.GetValue>("allCurrencies"); + var result = allCurrencies?.FirstOrDefault(x => x.Code.EqualsIgnoreCase(currencyCode)) + ?? throw new OperationCanceledException($"The currency with code '{currencyCode}' is not registered"); - public static string GetCultureName(this IResolveFieldContext context) - { - return context.GetArgumentOrValue(Constants.CultureName); + return result; } } } diff --git a/src/VirtoCommerce.Xapi.Core/Queries/BaseQueries/LocalizedSettingQueryBuilder.cs b/src/VirtoCommerce.Xapi.Core/Queries/BaseQueries/LocalizedSettingQueryBuilder.cs index 65d82e7..5eef2b0 100644 --- a/src/VirtoCommerce.Xapi.Core/Queries/BaseQueries/LocalizedSettingQueryBuilder.cs +++ b/src/VirtoCommerce.Xapi.Core/Queries/BaseQueries/LocalizedSettingQueryBuilder.cs @@ -1,3 +1,4 @@ +using System; using MediatR; using Microsoft.AspNetCore.Authorization; using VirtoCommerce.Xapi.Core.BaseQueries; @@ -10,8 +11,14 @@ namespace VirtoCommerce.Xapi.Core.Queries; public abstract class LocalizedSettingQueryBuilder : QueryBuilder where TQuery : LocalizedSettingQuery { + protected LocalizedSettingQueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] protected LocalizedSettingQueryBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } } diff --git a/src/VirtoCommerce.Xapi.Core/Schemas/CountryType.cs b/src/VirtoCommerce.Xapi.Core/Schemas/CountryType.cs index c608b38..3f20ecf 100644 --- a/src/VirtoCommerce.Xapi.Core/Schemas/CountryType.cs +++ b/src/VirtoCommerce.Xapi.Core/Schemas/CountryType.cs @@ -1,13 +1,15 @@ +using System; using GraphQL.Types; using MediatR; using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Xapi.Core.Extensions; using VirtoCommerce.Xapi.Core.Queries; namespace VirtoCommerce.Xapi.Core.Schemas { public class CountryType : ExtendableGraphType { - public CountryType(IMediator mediator) + public CountryType() { Field(x => x.Id, nullable: false).Description("Code of country. For example 'USA'."); Field(x => x.Name, nullable: false).Description("Name of country. For example 'United States of America'."); @@ -15,9 +17,16 @@ public CountryType(IMediator mediator) .Description("Country regions.") .ResolveAsync(async context => { - var response = await mediator.Send(new GetRegionsQuery() { CountryId = context.Source.Id }); + var response = await context.GetMediator().Send(new GetRegionsQuery { CountryId = context.Source.Id }); + return response.Regions; }); } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] + public CountryType(IMediator mediator) + : this() + { + } } } diff --git a/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyType.cs b/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyType.cs index 470b202..c1db1dd 100644 --- a/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyType.cs +++ b/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyType.cs @@ -1,6 +1,6 @@ +using System; using System.Linq; using System.Threading.Tasks; -using GraphQL; using GraphQL.Builders; using GraphQL.Types; using MediatR; @@ -15,7 +15,7 @@ namespace VirtoCommerce.Xapi.Core.Schemas { public class DynamicPropertyType : ExtendableGraphType { - public DynamicPropertyType(IMediator mediator) + public DynamicPropertyType() { Field(x => x.Id, nullable: false).Description("Id"); Field>("Name").Resolve(context => context.Source.Name); @@ -25,6 +25,7 @@ public DynamicPropertyType(IMediator mediator) .Resolve(context => { var culture = context.GetValue("cultureName"); + return context.Source.DisplayNames.FirstOrDefault(x => culture.IsNullOrEmpty() || x.Locale.EqualsIgnoreCase(culture))?.Name; }); Field(x => x.DisplayOrder, nullable: true).Description("The order for the dynamic property to display"); @@ -42,13 +43,16 @@ public DynamicPropertyType(IMediator mediator) .Argument("cultureName", "") .Argument("sort", "") .PageSize(Connections.DefaultPageSize) - .ResolveAsync(async context => - { - return await ResolveConnectionAsync(mediator, context); - }); + .ResolveAsync(async context => await ResolveConnectionAsync(context)); + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] + public DynamicPropertyType(IMediator mediator) + : this() + { } - private static async Task ResolveConnectionAsync(IMediator mediator, IResolveConnectionContext context) + private static async Task ResolveConnectionAsync(IResolveConnectionContext context) { _ = int.TryParse(context.After, out var skip); @@ -61,7 +65,7 @@ private static async Task ResolveConnectionAsync(IMediator mediator, IRe context.CopyArgumentsToUserContext(); - var response = await mediator.Send(query); + var response = await context.GetMediator().Send(query); return new PagedConnection(response.Results, query.Skip, query.Take, response.TotalCount); } diff --git a/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyValueType.cs b/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyValueType.cs index b9cdfaf..0f2ad29 100644 --- a/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyValueType.cs +++ b/src/VirtoCommerce.Xapi.Core/Schemas/DynamicPropertyValueType.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using GraphQL.Types; using MediatR; @@ -11,12 +12,8 @@ namespace VirtoCommerce.Xapi.Core.Schemas { public class DynamicPropertyValueType : ExtendableGraphType { - private readonly IDynamicPropertyDictionaryItemsService _dynamicPropertyDictionaryItemsService; - - public DynamicPropertyValueType(IMediator mediator, IDynamicPropertyDictionaryItemsService dynamicPropertyDictionaryItemsService) + public DynamicPropertyValueType(IDynamicPropertyDictionaryItemsService dynamicPropertyDictionaryItemsService) { - _dynamicPropertyDictionaryItemsService = dynamicPropertyDictionaryItemsService; - Field("name") .Description("Property name") .Resolve(context => context.Source.PropertyName); @@ -30,23 +27,23 @@ public DynamicPropertyValueType(IMediator mediator, IDynamicPropertyDictionaryIt .Description("Property value") .Resolve(context => context.Source.Value); - Field("dictionaryItem").Description("Associated dictionary item").ResolveAsync(async context => - { - var id = context.Source.ValueId; - if (id.IsNullOrEmpty()) + Field("dictionaryItem") + .Description("Associated dictionary item") + .ResolveAsync(async context => { - return null; - } + var id = context.Source.ValueId; - var items = await _dynamicPropertyDictionaryItemsService.GetDynamicPropertyDictionaryItemsAsync([id]); + return string.IsNullOrEmpty(id) + ? null + : (await dynamicPropertyDictionaryItemsService.GetDynamicPropertyDictionaryItemsAsync([id])).FirstOrDefault(); + }); - return items.FirstOrDefault(); - }); - - Field("dynamicProperty").Description("Associated dynamic property").ResolveAsync(async context => + Field("dynamicProperty") + .Description("Associated dynamic property") + .ResolveAsync(async context => { var id = context.Source.PropertyId; - if (id.IsNullOrEmpty()) + if (string.IsNullOrEmpty(id)) { return null; } @@ -54,10 +51,16 @@ public DynamicPropertyValueType(IMediator mediator, IDynamicPropertyDictionaryIt var query = context.GetDynamicPropertiesQuery(); query.IdOrName = id; - var response = await mediator.Send(query); + var response = await context.GetMediator().Send(query); return response.DynamicProperty; }); } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] + public DynamicPropertyValueType(IMediator mediator, IDynamicPropertyDictionaryItemsService dynamicPropertyDictionaryItemsService) + : this(dynamicPropertyDictionaryItemsService) + { + } } } diff --git a/src/VirtoCommerce.Xapi.Core/Services/IRequestScopedCache.cs b/src/VirtoCommerce.Xapi.Core/Services/IRequestScopedCache.cs new file mode 100644 index 0000000..2f208ca --- /dev/null +++ b/src/VirtoCommerce.Xapi.Core/Services/IRequestScopedCache.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace VirtoCommerce.Xapi.Core.Services +{ + /// + /// Request-scoped cache for expensive loads: deduplicates a load that is re-issued with identical + /// arguments many times within one GraphQL request. Concurrent same-key calls share a single factory invocation. + /// + /// + /// Use this instead of a DataLoader when many distinct items issue the same load (identical, or only a few + /// distinct, argument combinations): a DataLoader dedups by its own node key (LoadAsync(id)), not by the + /// load's arguments, so a load whose arguments repeat across differently-keyed nodes still fans out. This cache + /// dedups by the load's own stable argument key instead. + ///

+ /// Key rules: the key must include EVERY argument that affects the load's result (a missing one causes false + /// cache hits), multi-value parts of the key must be sorted for order independence, and the key must carry a + /// type/purpose-discriminating prefix so unrelated callers sharing the same scoped instance don't collide. + ///

+ /// Failure semantics: a faulted load is cached for the remainder of the request and is not retried - every + /// same-key call rethrows the original exception. + ///

+ /// Warning: consumers must be resolved from the request scope (e.g. a handler dependency, or + /// ) - never constructor-injected into a singleton + /// graph type or schema builder. + ///
+ public interface IRequestScopedCache + { + /// + /// Returns the result of a previous (possibly still in-flight) load registered under , + /// or invokes and caches its task for the remainder of the request. + /// + /// Result type; every caller of the same key must use the same . + /// Stable, order-independent key built from every load-affecting argument. + /// The load to execute on a cache miss; runs at most once per key per request. + /// is null. + /// The key was previously used with a different . + Task GetOrAddAsync(string key, Func> factory); + + /// + /// Batch by-id variant: serves ids already loaded during this request from the cache and loads only the + /// not-yet-cached ids in a single call, caching each item under its own + /// (, id) key - across overlapping id-sets, including concurrent callers, + /// every id is loaded at most once per request. + /// + /// + /// An id the load does not return is negatively cached: omitted from results for the rest of the request, + /// not retried. Loaded items with unrequested ids are ignored; duplicate ids - first wins. The returned + /// dictionary is created per call and may be mutated; the item instances in it are shared within the + /// request - treat them as read-only. must not call back into this cache + /// for ids it was asked to load (it would await its own load). + /// + /// Item type, a reference type so a not-found id is representable as a cached null; + /// every caller of the same (, id) pair must use the same . + /// Type/purpose-discriminating prefix, unique per load kind. + /// Identifiers to resolve; null/empty entries skipped, duplicates collapsed. + /// Extracts the cache id from a loaded item. + /// Batch load for the not-yet-cached ids; a null result counts as empty. + /// Found ids mapped to their items; not-found ids are omitted. + /// Any argument is null. + /// A (, id) pair was previously used with a different . + Task> GetOrLoadByIdsAsync( + string keyPrefix, + ICollection ids, + Func idSelector, + Func, Task>> loadMissing) + where T : class; + } +} diff --git a/src/VirtoCommerce.Xapi.Data/Extensions/ServiceCollectionExtensions.cs b/src/VirtoCommerce.Xapi.Data/Extensions/ServiceCollectionExtensions.cs index ee924cf..b005b19 100644 --- a/src/VirtoCommerce.Xapi.Data/Extensions/ServiceCollectionExtensions.cs +++ b/src/VirtoCommerce.Xapi.Data/Extensions/ServiceCollectionExtensions.cs @@ -56,6 +56,7 @@ public static IServiceCollection AddXCore(this IServiceCollection services, ICon services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddScoped(); services.AddDistributedLockService(configuration); return services; diff --git a/src/VirtoCommerce.Xapi.Data/Queries/GetStoreQueryBuilder.cs b/src/VirtoCommerce.Xapi.Data/Queries/GetStoreQueryBuilder.cs index dd7ed7a..682a446 100644 --- a/src/VirtoCommerce.Xapi.Data/Queries/GetStoreQueryBuilder.cs +++ b/src/VirtoCommerce.Xapi.Data/Queries/GetStoreQueryBuilder.cs @@ -1,3 +1,4 @@ +using System; using MediatR; using Microsoft.AspNetCore.Authorization; using VirtoCommerce.Xapi.Core.BaseQueries; @@ -11,8 +12,14 @@ public class GetStoreQueryBuilder : QueryBuilder "store"; + public GetStoreQueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] public GetStoreQueryBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } } diff --git a/src/VirtoCommerce.Xapi.Data/Queries/SlugInfoQueryBuilder.cs b/src/VirtoCommerce.Xapi.Data/Queries/SlugInfoQueryBuilder.cs index 994f18f..5ec89e2 100644 --- a/src/VirtoCommerce.Xapi.Data/Queries/SlugInfoQueryBuilder.cs +++ b/src/VirtoCommerce.Xapi.Data/Queries/SlugInfoQueryBuilder.cs @@ -1,3 +1,4 @@ +using System; using MediatR; using Microsoft.AspNetCore.Authorization; using VirtoCommerce.Xapi.Core.BaseQueries; @@ -9,8 +10,14 @@ namespace VirtoCommerce.Xapi.Data.Queries { public class SlugInfoQueryBuilder : QueryBuilder { + public SlugInfoQueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] public SlugInfoQueryBuilder(IMediator mediator, IAuthorizationService authorizationService) - : base(mediator, authorizationService) + : this(authorizationService) { } diff --git a/src/VirtoCommerce.Xapi.Data/Schemas/CoreSchema.cs b/src/VirtoCommerce.Xapi.Data/Schemas/CoreSchema.cs index f0c1c5e..e99aaa6 100644 --- a/src/VirtoCommerce.Xapi.Data/Schemas/CoreSchema.cs +++ b/src/VirtoCommerce.Xapi.Data/Schemas/CoreSchema.cs @@ -1,3 +1,4 @@ +using System; using GraphQL; using GraphQL.Resolvers; using GraphQL.Types; @@ -12,11 +13,14 @@ namespace VirtoCommerce.Xapi.Data.Schemas { public class CoreSchema : ISchemaBuilder { - private readonly IMediator _mediator; + public CoreSchema() + { + } + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] public CoreSchema(IMediator mediator) + : this() { - _mediator = mediator; } public void Build(ISchema schema) @@ -39,7 +43,7 @@ public void Build(ISchema schema) Type = GraphTypeExtensionHelper.GetActualComplexType>>>(), Resolver = new FuncFieldResolver(async context => { - var result = await _mediator.Send(new GetCountriesQuery()); + var result = await context.GetMediator().Send(new GetCountriesQuery()); return result.Countries; }) @@ -64,7 +68,7 @@ public void Build(ISchema schema) Type = GraphTypeExtensionHelper.GetActualComplexType>>>(), Resolver = new FuncFieldResolver(async context => { - var result = await _mediator.Send(new GetRegionsQuery + var result = await context.GetMediator().Send(new GetRegionsQuery { CountryId = context.GetArgument("countryId"), }); diff --git a/src/VirtoCommerce.Xapi.Data/Schemas/DynamicPropertySchema.cs b/src/VirtoCommerce.Xapi.Data/Schemas/DynamicPropertySchema.cs index 0d37c00..31292db 100644 --- a/src/VirtoCommerce.Xapi.Data/Schemas/DynamicPropertySchema.cs +++ b/src/VirtoCommerce.Xapi.Data/Schemas/DynamicPropertySchema.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using GraphQL.Builders; using GraphQL.Resolvers; @@ -15,11 +16,14 @@ namespace VirtoCommerce.Xapi.Data.Schemas { public class DynamicPropertySchema : ISchemaBuilder { - private readonly IMediator _mediator; + public DynamicPropertySchema() + { + } + [Obsolete("Use the constructor without IMediator. The mediator is resolved from context.RequestServices per request.", DiagnosticId = "VC0015", UrlFormat = "https://docs.virtocommerce.org/products/products-virto3-versions")] public DynamicPropertySchema(IMediator mediator) + : this() { - _mediator = mediator; } public void Build(ISchema schema) @@ -41,7 +45,7 @@ public void Build(ISchema schema) query.IdOrName = context.GetArgument("idOrName"); query.ObjectType = context.GetArgument("objectType"); - var response = await _mediator.Send(query); + var response = await context.GetMediator().Send(query); return response.DynamicProperty; }) @@ -55,12 +59,12 @@ public void Build(ISchema schema) .Argument("objectType", "Object type of the dynamic property") .PageSize(Connections.DefaultPageSize); - dynamicPropertiesConnectionBuilder.ResolveAsync(async context => await ResolveDynamicPropertiesConnectionAsync(_mediator, context)); + dynamicPropertiesConnectionBuilder.ResolveAsync(async context => await ResolveDynamicPropertiesConnectionAsync(context)); schema.Query.AddField(dynamicPropertiesConnectionBuilder.FieldType); } - private static async Task ResolveDynamicPropertiesConnectionAsync(IMediator mediator, IResolveConnectionContext context) + private static async Task ResolveDynamicPropertiesConnectionAsync(IResolveConnectionContext context) { int.TryParse(context.After, out var skip); @@ -73,7 +77,7 @@ private static async Task ResolveDynamicPropertiesConnectionAsync(IMedia context.CopyArgumentsToUserContext(); - var response = await mediator.Send(query); + var response = await context.GetMediator().Send(query); return new PagedConnection(response.Results, query.Skip, query.Take, response.TotalCount); } diff --git a/src/VirtoCommerce.Xapi.Data/Services/RequestScopedCache.cs b/src/VirtoCommerce.Xapi.Data/Services/RequestScopedCache.cs new file mode 100644 index 0000000..f3ecdf2 --- /dev/null +++ b/src/VirtoCommerce.Xapi.Data/Services/RequestScopedCache.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using VirtoCommerce.Xapi.Core.Services; + +namespace VirtoCommerce.Xapi.Data.Services; + +public class RequestScopedCache : IRequestScopedCache +{ + // Lazy -> the factory runs at most once per key even under a concurrent same-key miss. + // The Task itself is stored (not its unwrapped result), so value-type results are never boxed. + private readonly ConcurrentDictionary> _cache = new(); + + // By-id entries store the Task directly - single-flight comes from promise reservation, so there + // is no per-id factory whose start a Lazy could defer. The tuple key avoids the aliasing of string + // concatenation ("P:A"+"B" vs "P"+"A:B") and can't collide with by-key entries (separate dictionary). + private readonly ConcurrentDictionary<(string Prefix, string Id), Task> _cacheById = new(); + + public virtual Task GetOrAddAsync(string key, Func> factory) + { + ArgumentNullException.ThrowIfNull(factory); + + var lazy = _cache.GetOrAdd(key, static (_, arg) => new Lazy(arg), factory); + + return (Task)lazy.Value; + } + + public virtual Task> GetOrLoadByIdsAsync( + string keyPrefix, + ICollection ids, + Func idSelector, + Func, Task>> loadMissing) + where T : class + { + ArgumentException.ThrowIfNullOrEmpty(keyPrefix); + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(idSelector); + ArgumentNullException.ThrowIfNull(loadMissing); + + return GetOrLoadByIdsCoreAsync(keyPrefix, ids, idSelector, loadMissing); + } + + private async Task> GetOrLoadByIdsCoreAsync( + string keyPrefix, + ICollection ids, + Func idSelector, + Func, Task>> loadMissing) + where T : class + { + var result = new Dictionary(ids.Count); + List>> pending = null; + Dictionary> owned = null; + + try + { + // Single pass: only the reservation winner loads an id, so concurrent overlapping callers + // load each id at most once. No explicit dedup: a duplicate input id hits the added entry. + foreach (var id in ids) + { + if (string.IsNullOrEmpty(id)) + { + continue; + } + + var task = GetOrReserve((keyPrefix, id), out var reservation); + if (reservation is not null) + { + (owned ??= new Dictionary>()).Add(id, reservation); + } + + // A just-won reservation task is never completed here, so it lands in pending. + if (task.IsCompletedSuccessfully) + { + await CollectHitAsync(result, id, task); + } + else + { + (pending ??= []).Add(new KeyValuePair>(id, task)); + } + } + + if (owned is not null) + { + await LoadAndPublishAsync(owned, idSelector, loadMissing); + } + } + catch (Exception ex) + { + // Owned reservations must never leak incomplete - concurrent awaiters would hang. The + // cached fault makes same-id calls rethrow (by-key failure semantics). + FaultReservations(owned, ex); + throw; + } + + await CollectPendingAsync(pending, result); + + return result; + } + + private static async Task CollectHitAsync(Dictionary result, string id, Task task) + where T : class + { + // Called for completed tasks only - the await continues synchronously, no allocation. + var value = await task; + if (value is not null) + { + result[id] = value; + } + } + + private Task GetOrReserve((string Prefix, string Id) key, out TaskCompletionSource reservation) + where T : class + { + if (_cacheById.TryGetValue(key, out var cached)) + { + reservation = null; + + return (Task)cached; + } + + // RunContinuationsAsynchronously: completing the reservation must not run other callers' + // continuations inline on the publishing thread. + var candidate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cached = _cacheById.GetOrAdd(key, static (_, arg) => arg.Task, candidate); + reservation = ReferenceEquals(cached, candidate.Task) ? candidate : null; + + return (Task)cached; + } + + private static async Task LoadAndPublishAsync( + Dictionary> owned, + Func idSelector, + Func, Task>> loadMissing) + where T : class + { + // Null result counts as empty, mirroring the platform's GetOrLoadByIdsAsync. + var loaded = await loadMissing(owned.Keys) ?? []; + + foreach (var item in loaded) + { + if (item is null) + { + continue; + } + + var loadedId = idSelector(item); + if (!string.IsNullOrEmpty(loadedId) && owned.TryGetValue(loadedId, out var reservation)) + { + // TrySetResult: duplicate ids from the load - first wins. + reservation.TrySetResult(item); + } + } + + // Not-returned owned ids: negatively cache as null for the request. + foreach (var reservation in owned.Values) + { + reservation.TrySetResult(null); + } + } + + private static void FaultReservations(Dictionary> owned, Exception exception) + where T : class + { + if (owned is null) + { + return; + } + + foreach (var reservation in owned.Values) + { + reservation.TrySetException(exception); + } + } + + private static async Task CollectPendingAsync(List>> pending, Dictionary result) + where T : class + { + if (pending is null) + { + return; + } + + foreach (var (id, task) in pending) + { + var value = await task; + if (value is not null) + { + result.TryAdd(id, value); + } + } + } +} diff --git a/tests/VirtoCommerce.Xapi.Tests/BaseQueries/RequestBuilderTests.cs b/tests/VirtoCommerce.Xapi.Tests/BaseQueries/RequestBuilderTests.cs new file mode 100644 index 0000000..a2f37aa --- /dev/null +++ b/tests/VirtoCommerce.Xapi.Tests/BaseQueries/RequestBuilderTests.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GraphQL; +using GraphQL.Types; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using VirtoCommerce.Xapi.Core.BaseQueries; +using Xunit; + +namespace VirtoCommerce.Xapi.Tests.BaseQueries +{ + public class RequestBuilderTests + { + [Fact] + public async Task GetResponseAsync_ResolvesMediatorFromRequestScope_NotFromConstructor() + { + var expectedResponse = new TestResponse { Value = "expected" }; + var mediatorMock = new Mock(); + mediatorMock + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResponse); + + var services = new ServiceCollection(); + services.AddSingleton(mediatorMock.Object); + var provider = services.BuildServiceProvider(); + + var sut = new TestQueryBuilder(Mock.Of()); + var context = new ResolveFieldContext + { + RequestServices = provider, + }; + + var (request, response) = await sut.InvokeResolve(context); + + request.Should().NotBeNull(); + response.Should().BeSameAs(expectedResponse); + mediatorMock.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Once); + } + + private sealed class TestResponse + { + public string Value { get; set; } + } + + private sealed class TestQuery : Query + { + public override IEnumerable GetArguments() + { + yield break; + } + + public override void Map(IResolveFieldContext context) + { + } + } + + private sealed class TestQueryBuilder : QueryBuilder + { + protected override string Name => "test"; + + public TestQueryBuilder(IAuthorizationService authorizationService) + : base(authorizationService) + { + } + + public Task<(TestQuery Request, TestResponse Response)> InvokeResolve(IResolveFieldContext context) + { + return Resolve(context); + } + } + } +} diff --git a/tests/VirtoCommerce.Xapi.Tests/Extensions/ResolveFieldContextExtensionsTests.cs b/tests/VirtoCommerce.Xapi.Tests/Extensions/ResolveFieldContextExtensionsTests.cs new file mode 100644 index 0000000..305df0c --- /dev/null +++ b/tests/VirtoCommerce.Xapi.Tests/Extensions/ResolveFieldContextExtensionsTests.cs @@ -0,0 +1,45 @@ +using System; +using FluentAssertions; +using GraphQL; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using VirtoCommerce.Xapi.Core.Extensions; +using Xunit; + +namespace VirtoCommerce.Xapi.Tests.Extensions +{ + public class ResolveFieldContextExtensionsTests + { + [Fact] + public void GetMediator_RequestServicesPopulated_ReturnsMediatorFromRequestScope() + { + var mediatorMock = new Mock(); + var services = new ServiceCollection(); + services.AddSingleton(mediatorMock.Object); + var provider = services.BuildServiceProvider(); + + var context = new ResolveFieldContext + { + RequestServices = provider, + }; + + var result = context.GetMediator(); + + result.Should().BeSameAs(mediatorMock.Object); + } + + [Fact] + public void GetMediator_RequestServicesNull_Throws() + { + var context = new ResolveFieldContext + { + RequestServices = null, + }; + + Action act = () => context.GetMediator(); + + act.Should().Throw().WithMessage("*RequestServices*"); + } + } +} diff --git a/tests/VirtoCommerce.Xapi.Tests/GraphQLSingletonMediatorInjectionTests.cs b/tests/VirtoCommerce.Xapi.Tests/GraphQLSingletonMediatorInjectionTests.cs new file mode 100644 index 0000000..ce8705b --- /dev/null +++ b/tests/VirtoCommerce.Xapi.Tests/GraphQLSingletonMediatorInjectionTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using GraphQL.Types; +using MediatR; +using VirtoCommerce.Xapi.Core.Infrastructure; +using VirtoCommerce.Xapi.Data.Extensions; +using Xunit; + +namespace VirtoCommerce.Xapi.Tests +{ + public class GraphQLSingletonMediatorInjectionTests + { + [Fact] + public void GraphQLSingletonTypes_ShouldNotCtorInjectMediator() + { + var assemblies = new[] + { + typeof(ISchemaBuilder).Assembly, // VirtoCommerce.Xapi.Core + typeof(ServiceCollectionExtensions).Assembly, // VirtoCommerce.Xapi.Data + }; + + var offendingConstructors = + from assembly in assemblies + from type in assembly.GetTypes() + where !type.IsAbstract && (typeof(IGraphType).IsAssignableFrom(type) || typeof(ISchemaBuilder).IsAssignableFrom(type)) + from ctor in type.GetConstructors() + where ctor.GetCustomAttribute() is null + where ctor.GetParameters().Any(parameter => parameter.ParameterType == typeof(IMediator)) + select $"{type.FullName}({string.Join(", ", ctor.GetParameters().Select(parameter => parameter.ParameterType.Name))})"; + + offendingConstructors.Should().BeEmpty( + "GraphQL types and schema builders are singletons (built once with the schema); a non-obsolete " + + "constructor-injected IMediator would be captured against the root service provider and break (or silently " + + "un-scope) any Scoped dependency of the handlers it dispatches to - resolve it per request instead via " + + "IResolveFieldContext.GetMediator()."); + } + } +} diff --git a/tests/VirtoCommerce.Xapi.Tests/Services/RequestScopedCacheTests.cs b/tests/VirtoCommerce.Xapi.Tests/Services/RequestScopedCacheTests.cs new file mode 100644 index 0000000..5fd5ad2 --- /dev/null +++ b/tests/VirtoCommerce.Xapi.Tests/Services/RequestScopedCacheTests.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Xapi.Core.Extensions; +using VirtoCommerce.Xapi.Data.Services; +using Xunit; + +namespace VirtoCommerce.Xapi.Tests.Services +{ + public class RequestScopedCacheTests + { + [Fact] + public async Task GetOrAddAsync_SameKey_FactoryExecutedOnce() + { + var sut = new RequestScopedCache(); + var callCount = 0; + + Task Factory() + { + Interlocked.Increment(ref callCount); + return Task.FromResult("value"); + } + + var first = await sut.GetOrAddAsync("key", Factory); + var second = await sut.GetOrAddAsync("key", Factory); + + first.Should().Be("value"); + second.Should().Be("value"); + callCount.Should().Be(1, "the second call for the same key must be served from the cached result, not re-invoke the factory"); + } + + [Fact] + public async Task GetOrAddAsync_DifferentKeys_FactoryExecutedPerKey() + { + var sut = new RequestScopedCache(); + var callCount = 0; + + Task Factory() + { + return Task.FromResult(Interlocked.Increment(ref callCount)); + } + + var first = await sut.GetOrAddAsync("key-1", Factory); + var second = await sut.GetOrAddAsync("key-2", Factory); + + first.Should().Be(1); + second.Should().Be(2); + callCount.Should().Be(2, "distinct keys must not share a cached result"); + } + + [Fact] + public async Task GetOrAddAsync_ConcurrentSameKey_FactoryExecutedExactlyOnce() + { + // The single-flight guarantee: a burst of concurrent callers racing on the same key must + // all observe the same in-flight Task rather than each starting its own factory invocation. + var sut = new RequestScopedCache(); + var callCount = 0; + var release = new TaskCompletionSource(); + + async Task Factory() + { + Interlocked.Increment(ref callCount); + await release.Task; + return 42; + } + + const int callers = 20; + var tasks = new Task[callers]; + for (var i = 0; i < callers; i++) + { + // Task.Run forces the callers onto distinct pool threads so they race on GetOrAdd for real, + // instead of entering it cooperatively from one thread. + tasks[i] = Task.Run(() => sut.GetOrAddAsync("key", Factory)); + } + + // Give every caller a chance to reach GetOrAddAsync before releasing the factory. + await Task.Delay(20); + release.SetResult(); + + var results = await Task.WhenAll(tasks); + + callCount.Should().Be(1, "concurrent callers on the same key must share a single in-flight factory invocation"); + results.Should().OnlyContain(x => x == 42); + } + + [Fact] + public async Task GetOrAddAsync_DifferentResultTypesPerKey_BothResolveCorrectly() + { + var sut = new RequestScopedCache(); + + var stringResult = await sut.GetOrAddAsync("string-key", () => Task.FromResult("text")); + var intResult = await sut.GetOrAddAsync("int-key", () => Task.FromResult(7)); + + stringResult.Should().Be("text"); + intResult.Should().Be(7); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_LoadsAllMissingIdsInOneBatch() + { + var sut = new RequestScopedCache(); + var batches = new List(); + + var result = await sut.GetOrLoadByIdsAsync("prefix", ["a", "b", "c"], x => x.Id, CreateLoader(batches)); + + batches.Should().ContainSingle().Which.Should().BeEquivalentTo("a", "b", "c"); + result.Keys.Should().BeEquivalentTo("a", "b", "c"); + result["a"].Id.Should().Be("a"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_OverlappingCall_LoadsOnlyNotYetCachedIds() + { + var sut = new RequestScopedCache(); + var batches = new List(); + var loadMissing = CreateLoader(batches); + + var first = await sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, loadMissing); + var second = await sut.GetOrLoadByIdsAsync("prefix", ["b", "c"], x => x.Id, loadMissing); + var third = await sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, loadMissing); + + batches.Should().HaveCount(2, "the second call must load only the ids the first call did not cache, and the third call must load nothing"); + batches[1].Should().BeEquivalentTo("c"); + second.Keys.Should().BeEquivalentTo("b", "c"); + second["b"].Should().BeSameAs(first["b"], "an overlapping id must be served from the cache, not re-loaded"); + third["a"].Should().BeSameAs(first["a"]); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_NotFoundId_OmittedAndNegativelyCached() + { + var sut = new RequestScopedCache(); + var batches = new List(); + // The loader never returns an item for "ghost". + var loadMissing = CreateLoader(batches, x => x == "ghost" ? null : new Item(x)); + + var first = await sut.GetOrLoadByIdsAsync("prefix", ["a", "ghost"], x => x.Id, loadMissing); + var second = await sut.GetOrLoadByIdsAsync("prefix", ["a", "ghost"], x => x.Id, loadMissing); + + first.Keys.Should().BeEquivalentTo("a"); + second.Keys.Should().BeEquivalentTo("a"); + batches.Should().ContainSingle("a not-found id must be negatively cached for the request, not re-loaded"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_NullEmptyAndDuplicateIds_NormalizedWithinCall() + { + var sut = new RequestScopedCache(); + var batches = new List(); + + var result = await sut.GetOrLoadByIdsAsync("prefix", [null, "", "a", "a", "b"], x => x.Id, CreateLoader(batches)); + + batches.Should().ContainSingle().Which.Should().BeEquivalentTo("a", "b"); + result.Keys.Should().BeEquivalentTo("a", "b"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_EmptyIds_ReturnsEmptyWithoutLoad() + { + var sut = new RequestScopedCache(); + var batches = new List(); + + var result = await sut.GetOrLoadByIdsAsync("prefix", Array.Empty(), x => x.Id, CreateLoader(batches)); + + result.Should().BeEmpty(); + batches.Should().BeEmpty(); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_FaultedLoad_CachedForTheRequestAndRethrown() + { + var sut = new RequestScopedCache(); + var callCount = 0; + + Task> LoadMissing(ICollection missingIds) + { + Interlocked.Increment(ref callCount); + throw new InvalidOperationException("boom"); + } + + var firstCall = () => sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, LoadMissing); + var secondCall = () => sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, LoadMissing); + + await firstCall.Should().ThrowAsync().WithMessage("boom"); + await secondCall.Should().ThrowAsync().WithMessage("boom"); + callCount.Should().Be(1, "a faulted load must be cached for the request - same-id calls rethrow without re-loading"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_NullLoaderResult_TreatedAsEmptyAndNegativelyCached() + { + var sut = new RequestScopedCache(); + var callCount = 0; + + Task> LoadMissing(ICollection missingIds) + { + Interlocked.Increment(ref callCount); + return Task.FromResult>(null); + } + + var first = await sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, LoadMissing); + var second = await sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, LoadMissing); + + first.Should().BeEmpty(); + second.Should().BeEmpty(); + callCount.Should().Be(1, "ids from a null (empty) load result are negatively cached for the request, not re-loaded"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_InFlightLoad_SharedByLaterCallInsteadOfSecondLoad() + { + var sut = new RequestScopedCache(); + var release = new TaskCompletionSource(); + var callCount = 0; + + async Task> LoadMissing(ICollection missingIds) + { + Interlocked.Increment(ref callCount); + await release.Task; + return missingIds.Select(x => new Item(x)).ToList(); + } + + // The first call dispatches the load synchronously up to the await, then stays in flight. + var firstTask = sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, LoadMissing); + var secondTask = sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, LoadMissing); + + firstTask.IsCompleted.Should().BeFalse(); + secondTask.IsCompleted.Should().BeFalse(); + release.SetResult(); + + var first = await firstTask; + var second = await secondTask; + + callCount.Should().Be(1, "the second caller must await the in-flight load, not start its own"); + second["a"].Should().BeSameAs(first["a"]); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_ConcurrentOverlappingCallers_EachIdLoadedAtMostOnce() + { + // Regression guard for per-caller full-batch amplification: under concurrent overlapping + // misses, each id must be loaded by exactly one caller, so the total load across all + // callers is the distinct union - never a caller's full missing set re-loaded. + const int callers = 8; + const int windowSize = 12; + const int windowStride = 4; + var sut = new RequestScopedCache(); + var release = new TaskCompletionSource(); + var batches = new List(); + + async Task> LoadMissing(ICollection missingIds) + { + lock (batches) + { + batches.Add([.. missingIds]); + } + + await release.Task; + return missingIds.Select(x => new Item(x)).ToList(); + } + + var tasks = new Task>[callers]; + for (var i = 0; i < callers; i++) + { + var callerIds = Enumerable.Range(i * windowStride, windowSize).Select(x => $"id-{x}").ToArray(); + tasks[i] = Task.Run(() => sut.GetOrLoadByIdsAsync("prefix", callerIds, x => x.Id, LoadMissing)); + } + + // Give every caller a chance to reserve its ids before any load completes. + await Task.Delay(20); + release.SetResult(); + + var results = await Task.WhenAll(tasks); + + var idSpace = (callers - 1) * windowStride + windowSize; + var loadedIds = batches.SelectMany(x => x).ToList(); + loadedIds.Should().OnlyHaveUniqueItems("an id must never appear in two batches within one request"); + loadedIds.Should().BeEquivalentTo( + Enumerable.Range(0, idSpace).Select(x => $"id-{x}"), + "the union of all batches must be exactly the distinct union of requested ids"); + batches.Count.Should().BeLessThanOrEqualTo(callers, "each caller dispatches at most one batch"); + + for (var i = 0; i < callers; i++) + { + results[i].Keys.Should().BeEquivalentTo(Enumerable.Range(i * windowStride, windowSize).Select(x => $"id-{x}")); + } + + // Overlapping ids resolve to the same shared instance across callers. + results[1]["id-4"].Should().BeSameAs(results[0]["id-4"]); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_TupleKeys_DoNotCollideAcrossPrefixesOrWithByKeyEntries() + { + var sut = new RequestScopedCache(); + var batches = new List(); + var loadMissing = CreateLoader(batches); + + // "P" + "A:B" and "P:A" + "B" would alias under naive string concatenation. + var first = await sut.GetOrLoadByIdsAsync("P", ["A:B"], x => x.Id, loadMissing); + var second = await sut.GetOrLoadByIdsAsync("P:A", ["B"], x => x.Id, loadMissing); + var byKey = await sut.GetOrAddAsync("P:A:B", () => Task.FromResult("by-key value")); + + batches.Should().HaveCount(2, "entries under different prefixes must not alias"); + first["A:B"].Id.Should().Be("A:B"); + second["B"].Id.Should().Be("B"); + byKey.Should().Be("by-key value"); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_ExtraAndDuplicateLoadedItems_FirstWinsAndExtrasIgnored() + { + var sut = new RequestScopedCache(); + var requestedFirst = new Item("a"); + var requestedDuplicate = new Item("a"); + var batches = new List(); + var loadMissing = CreateLoader(batches); + + var result = await sut.GetOrLoadByIdsAsync( + "prefix", + ["a"], + x => x.Id, + _ => Task.FromResult>([requestedFirst, requestedDuplicate, new Item("x")])); + + result.Keys.Should().BeEquivalentTo("a"); + result["a"].Should().BeSameAs(requestedFirst, "the first loaded item for an id wins"); + + // The unrequested extra item must not have been cached. + var extra = await sut.GetOrLoadByIdsAsync("prefix", ["x"], x => x.Id, loadMissing); + batches.Should().ContainSingle().Which.Should().BeEquivalentTo("x"); + extra["x"].Should().NotBeNull(); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_SamePrefixAndIdWithDifferentType_ThrowsInvalidCast() + { + var sut = new RequestScopedCache(); + + await sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, CreateLoader([])); + + var act = () => sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, _ => Task.FromResult>([])); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_InvalidArguments_Throw() + { + var sut = new RequestScopedCache(); + var loadMissing = CreateLoader([]); + + var emptyPrefix = () => sut.GetOrLoadByIdsAsync("", ["a"], x => x.Id, loadMissing); + var nullIds = () => sut.GetOrLoadByIdsAsync("prefix", null, x => x.Id, loadMissing); + var nullSelector = () => sut.GetOrLoadByIdsAsync("prefix", ["a"], null, loadMissing); + var nullLoader = () => sut.GetOrLoadByIdsAsync("prefix", ["a"], x => x.Id, null); + + await emptyPrefix.Should().ThrowAsync(); + await nullIds.Should().ThrowAsync(); + await nullSelector.Should().ThrowAsync(); + await nullLoader.Should().ThrowAsync(); + } + + [Fact] + public async Task GetOrLoadByIdsAsync_EntityOverload_KeysByEntityIdAndSharesEntries() + { + var sut = new RequestScopedCache(); + var batches = new List(); + + Task> LoadMissing(ICollection missingIds) + { + lock (batches) + { + batches.Add([.. missingIds]); + } + + return Task.FromResult>(missingIds.Select(x => new EntityItem { Id = x }).ToList()); + } + + var viaEntityOverload = await sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], LoadMissing); + var viaCoreOverload = await sut.GetOrLoadByIdsAsync("prefix", ["a", "b"], x => x.Id, LoadMissing); + + batches.Should().ContainSingle("the IEntity overload must delegate to the core overload and share its cache entries"); + viaEntityOverload.Keys.Should().BeEquivalentTo("a", "b"); + viaCoreOverload["a"].Should().BeSameAs(viaEntityOverload["a"]); + } + + private static Func, Task>> CreateLoader( + List batches, + Func createItem = null) + { + createItem ??= x => new Item(x); + + return missingIds => + { + lock (batches) + { + batches.Add([.. missingIds]); + } + + var items = missingIds.Select(createItem).Where(x => x is not null).ToList(); + + return Task.FromResult>(items); + }; + } + + private sealed record Item(string Id); + + private sealed record OtherItem(string Id); + + private sealed class EntityItem : Entity + { + } + } +}