diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 4218c2e3dc..2f466a3fcb 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -4,8 +4,8 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging.Abstractions; @@ -37,11 +37,23 @@ public QueryStringParserBenchmarks() var resourceFactory = new ResourceFactory(new ServiceContainer()); - var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); - var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options); - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); - var paginationReader = new PaginationQueryStringParameterReader(request, resourceGraph, options); + var includeParser = new IncludeParser(options); + var includeReader = new IncludeQueryStringParameterReader(includeParser, request, resourceGraph); + + var filterScopeParser = new QueryStringParameterScopeParser(); + var filterValueParser = new FilterParser(resourceFactory); + var filterReader = new FilterQueryStringParameterReader(filterScopeParser, filterValueParser, request, resourceGraph, options); + + var sortScopeParser = new QueryStringParameterScopeParser(); + var sortValueParser = new SortParser(); + var sortReader = new SortQueryStringParameterReader(sortScopeParser, sortValueParser, request, resourceGraph); + + var sparseFieldSetScopeParser = new SparseFieldTypeParser(resourceGraph); + var sparseFieldSetValueParser = new SparseFieldSetParser(); + var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(sparseFieldSetScopeParser, sparseFieldSetValueParser, request, resourceGraph); + + var paginationParser = new PaginationParser(); + var paginationReader = new PaginationQueryStringParameterReader(paginationParser, request, resourceGraph, options); IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader); diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 5bb97a3156..458c4eecae 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 3f9efcc11d..a2d76b87b1 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index d9cfefd0b6..4b16afb393 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Response; diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs index 3de20cb7fd..a6f7ca1789 100644 --- a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,6 +14,7 @@ namespace Benchmarks.Tools; internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor { bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IQueryableBuilder QueryableBuilder => throw new NotImplementedException(); public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs index fb65a46015..f3eca749eb 100644 --- a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Linq.Expressions; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,13 +9,13 @@ namespace NoEntityFrameworkExample; internal sealed class QueryLayerToLinqConverter { - private readonly IResourceFactory _resourceFactory; private readonly IModel _model; + private readonly IQueryableBuilder _queryableBuilder; - public QueryLayerToLinqConverter(IResourceFactory resourceFactory, IModel model) + public QueryLayerToLinqConverter(IModel model, IQueryableBuilder queryableBuilder) { - _resourceFactory = resourceFactory; _model = model; + _queryableBuilder = queryableBuilder; } public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) @@ -26,10 +26,9 @@ public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, converter.ConvertIncludesToSelections(); // Convert QueryLayer into LINQ expression. - Expression source = ((IEnumerable)resources).AsQueryable().Expression; - var nameFactory = new LambdaParameterNameFactory(); - var queryableBuilder = new QueryableBuilder(source, queryLayer.ResourceType.ClrType, typeof(Enumerable), nameFactory, _resourceFactory, _model); - Expression expression = queryableBuilder.ApplyQuery(queryLayer); + IQueryable source = ((IEnumerable)resources).AsQueryable(); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Enumerable), _model, null); + Expression expression = _queryableBuilder.ApplyQuery(queryLayer, context); // Insert null checks to prevent a NullReferenceException during execution of expressions such as: // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs index 0b88ee3222..243b484a9b 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using NoEntityFrameworkExample.Data; @@ -25,12 +25,12 @@ public abstract class InMemoryResourceRepository : IResourceRead private readonly ResourceType _resourceType; private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; - protected InMemoryResourceRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + protected InMemoryResourceRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) { _resourceType = resourceGraph.GetResourceType(); var model = new InMemoryModel(resourceGraph); - _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder); } /// diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs index d710cff0de..4a2fc5e72a 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class PersonRepository : InMemoryResourceRepository { - public PersonRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public PersonRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs index da38005bb3..30661d8bc1 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TagRepository : InMemoryResourceRepository { - public TagRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public TagRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs index 38cd656e0a..8156bf2798 100644 --- a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Queries.QueryableBuilding; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -9,8 +9,8 @@ namespace NoEntityFrameworkExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemRepository : InMemoryResourceRepository { - public TodoItemRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) + public TodoItemRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder) + : base(resourceGraph, queryableBuilder) { } diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs index de9450298f..510d750fa7 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Services; @@ -42,7 +42,7 @@ public abstract class InMemoryResourceService : IResourceQuerySe private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, - IResourceFactory resourceFactory, IPaginationContext paginationContext, IEnumerable constraintProviders, + IPaginationContext paginationContext, IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, ILoggerFactory loggerFactory) { _options = options; @@ -54,7 +54,7 @@ protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resour _resourceType = resourceGraph.GetResourceType(); var model = new InMemoryModel(resourceGraph); - _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder); } /// diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs index 11a4ad0b4a..5f8f96e0c6 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -10,9 +11,9 @@ namespace NoEntityFrameworkExample.Services; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemService : InMemoryResourceService { - public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IResourceFactory resourceFactory, - IPaginationContext paginationContext, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(options, resourceGraph, queryLayerComposer, resourceFactory, paginationContext, constraintProviders, loggerFactory) + public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, ILoggerFactory loggerFactory) + : base(options, resourceGraph, queryLayerComposer, paginationContext, constraintProviders, queryableBuilder, loggerFactory) { } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs index bb854dac23..c129b4fdab 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs similarity index 83% rename from src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs rename to src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs index b209964232..14c35b8e26 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs @@ -3,13 +3,31 @@ #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Resources.Internal; +namespace JsonApiDotNetCore.Resources; +/// +/// Provides utilities regarding runtime types. +/// [PublicAPI] public static class RuntimeTypeConverter { private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture"; + /// + /// Converts the specified value to the specified type. + /// + /// + /// The value to convert from. + /// + /// + /// The type to convert to. + /// + /// + /// The converted type, or null if is null and is a nullable type. + /// + /// + /// is not compatible with . + /// public static object? ConvertType(object? value, Type type) { ArgumentGuard.NotNull(type); @@ -114,11 +132,20 @@ public static class RuntimeTypeConverter } } + /// + /// Indicates whether the specified type is a nullable value type or a reference type. + /// public static bool CanContainNull(Type type) { return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } + /// + /// Gets the default value for the specified type. + /// + /// + /// The default value, or null for nullable value types and reference types. + /// public static object? GetDefaultValue(Type type) { return type.IsValueType ? Activator.CreateInstance(type) : null; diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6ecdfd6077..73306e1237 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 6a17fed7b9..f413898269 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -32,6 +32,18 @@ public static int FindIndex(this IReadOnlyList source, Predicate match) return -1; } + public static IEnumerable ToEnumerable(this LinkedListNode? startNode) + { + LinkedListNode? current = startNode; + + while (current != null) + { + yield return current.Value; + + current = current.Next; + } + } + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, IEqualityComparer? valueComparer = null) { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 82e0ff52e1..c0b4638e40 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -2,9 +2,9 @@ using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.JsonConverters; @@ -193,6 +193,13 @@ private void AddRepositoryLayer() RegisterImplementationForInterfaces(ServiceDiscoveryFacade.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped(); + + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); } private void AddServiceLayer() @@ -210,6 +217,14 @@ private void RegisterImplementationForInterfaces(HashSet unboundInterfaces private void AddQueryStringLayer() { + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs similarity index 97% rename from src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs rename to src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs index 6f3e0caf4e..cfbb0ab46b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index ecacc41c7c..dfa7536b03 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -1,17 +1,29 @@ using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') +/// This expression allows to test if an attribute value equals any of the specified constants. It represents the "any" filter function, resulting from +/// text such as: +/// +/// any(owner.name,'Jack','Joe','John') +/// +/// . /// [PublicAPI] public class AnyExpression : FilterExpression { + /// + /// The attribute whose value to compare. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// One or more constants to compare the attribute's value against. + /// public IImmutableSet Constants { get; } public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index cdae713f3d..9259560776 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -4,13 +4,38 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') +/// This expression allows to compare two operands using a comparison operator. It represents comparison filter functions, resulting from text such as: +/// +/// equals(name,'Joe') +/// +/// , +/// +/// equals(owner,null) +/// +/// , or: +/// +/// greaterOrEqual(count(upVotes),count(downVotes),'1') +/// +/// . /// [PublicAPI] public class ComparisonExpression : FilterExpression { + /// + /// The operator used to compare and . + /// public ComparisonOperator Operator { get; } + + /// + /// The left-hand operand, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// When comparing equality with null, the chain may also end in a to-one relationship. + /// public QueryExpression Left { get; } + + /// + /// The right-hand operand, which can be a function, a field chain, a constant, or null (if the type of is nullable). Chain format: + /// an optional list of to-one relationships, followed by an attribute. + /// public QueryExpression Right { get; } public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 2eff0a86e9..960cf6371b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,16 +1,29 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "count" function, resulting from text such as: count(articles) +/// This expression allows to determine the number of related resources in a to-many relationship. It represents the "count" function, resulting from +/// text such as: +/// +/// count(articles) +/// +/// . /// [PublicAPI] public class CountExpression : FunctionExpression { + /// + /// The to-many relationship to count related resources for. Chain format: an optional list of to-one relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + public CountExpression(ResourceFieldChainExpression targetCollection) { ArgumentGuard.NotNull(targetCollection); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs index 513fbf9ac8..447c8b6138 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FilterExpression : FunctionExpression { + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(bool); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs index 2e0b76b255..886a3906c8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FunctionExpression : QueryExpression { + /// + /// The CLR type this function returns. + /// + public abstract Type ReturnType { get; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index 825119fe33..709fd60916 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -1,16 +1,33 @@ using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) +/// This expression allows to test if a to-many relationship has related resources, optionally with a condition. It represents the "has" filter function, +/// resulting from text such as: +/// +/// has(articles) +/// +/// , or: +/// +/// has(articles,equals(isHidden,'false')) +/// +/// . /// [PublicAPI] public class HasExpression : FilterExpression { + /// + /// The to-many relationship to determine related resources for. Chain format: an optional list of to-one relationships, followed by a to-many + /// relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + + /// + /// An optional filter that is applied on the related resources. Any related resources that do not match the filter are ignored. + /// public FilterExpression? Filter { get; } public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 133af83ad4..5b7d63ef96 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -1,7 +1,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the base type for an identifier, such as a field/relationship name, a constant between quotes or null. +/// Represents the base type for an identifier, such as a JSON:API attribute/relationship name, a constant value between quotes, or null. /// public abstract class IdentifierExpression : QueryExpression { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index 01c25dad4e..76991c6e78 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -6,12 +6,23 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in an tree, resulting from text such as: +/// +/// articles.revisions +/// +/// . /// [PublicAPI] public class IncludeElementExpression : QueryExpression { + /// + /// The JSON:API relationship to include. + /// public RelationshipAttribute Relationship { get; } + + /// + /// The direct children of this subtree. Can be empty. + /// public IImmutableSet Children { get; } public IncludeElementExpression(RelationshipAttribute relationship) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 69373c9abf..235e811fff 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -4,7 +4,11 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an inclusion tree, resulting from text such as: owner,articles.revisions +/// Represents an inclusion tree, resulting from text such as: +/// +/// owner,articles.revisions +/// +/// . /// [PublicAPI] public class IncludeExpression : QueryExpression @@ -13,6 +17,9 @@ public class IncludeExpression : QueryExpression public static readonly IncludeExpression Empty = new(); + /// + /// The direct children of this tree. Use if there are no children. + /// public IImmutableSet Elements { get; } public IncludeExpression(IImmutableSet elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs index 4e259b358e..c916aff3bc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -1,19 +1,42 @@ using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "isType" filter function, resulting from text such as: isType(,men), isType(creator,men) or +/// This expression allows to test if a resource in an inheritance hierarchy can be upcast to a derived type, optionally with a condition where the +/// derived type is accessible. It represents the "isType" filter function, resulting from text such as: +/// +/// isType(,men) +/// +/// , +/// +/// isType(creator,men) +/// +/// , or: +/// /// isType(creator,men,equals(hasBeard,'true')) +/// +/// . /// [PublicAPI] public class IsTypeExpression : FilterExpression { + /// + /// An optional to-one relationship to start from. Chain format: one or more to-one relationships. + /// public ResourceFieldChainExpression? TargetToOneRelationship { get; } + + /// + /// The derived resource type to upcast to. + /// public ResourceType DerivedType { get; } + + /// + /// An optional filter that the derived resource must match. + /// public FilterExpression? Child { get; } public IsTypeExpression(ResourceFieldChainExpression? targetToOneRelationship, ResourceType derivedType, FilterExpression? child) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index e5ca0c0318..1592aadcfd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -4,14 +4,17 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') +/// Represents a non-null constant value, resulting from text such as: 'Jack', '123', or: 'true'. /// [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { - // Only used to show the original input, in case expression parse failed. Not part of the semantic expression value. + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. private readonly string _stringValue; + /// + /// The constant value. Call to determine the .NET runtime type. + /// public object TypedValue { get; } public LiteralConstantExpression(object typedValue) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 08f970aee5..21a1b61e1c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -6,12 +6,28 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) +/// This expression allows to test whether one or all of its boolean operands are true. It represents the logical AND/OR filter functions, resulting from +/// text such as: +/// +/// and(equals(title,'Work'),has(articles)) +/// +/// , or: +/// +/// or(equals(title,'Work'),has(articles)) +/// +/// . /// [PublicAPI] public class LogicalExpression : FilterExpression { + /// + /// The operator used to compare . + /// public LogicalOperator Operator { get; } + + /// + /// The list of one or more boolean operands. + /// public IImmutableList Terms { get; } public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 5d9ed08859..70a8fda395 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -5,13 +5,37 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') +/// This expression allows partial matching on the value of a JSON:API attribute. It represents text-matching filter functions, resulting from text such +/// as: +/// +/// startsWith(name,'The') +/// +/// , +/// +/// endsWith(name,'end.') +/// +/// , or: +/// +/// contains(name,'middle') +/// +/// . /// [PublicAPI] public class MatchTextExpression : FilterExpression { + /// + /// The attribute whose value to match. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The text to match the attribute's value against. + /// public LiteralConstantExpression TextValue { get; } + + /// + /// The kind of matching to perform. + /// public TextMatchKind MatchKind { get; } public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index ae198cd3ee..eaafb71afa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,14 +1,21 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) +/// This expression allows to test for the logical negation of its operand. It represents the "not" filter function, resulting from text such as: +/// +/// not(equals(title,'Work')) +/// +/// . /// [PublicAPI] public class NotExpression : FilterExpression { + /// + /// The filter whose value to negate. + /// public FilterExpression Child { get; } public NotExpression(FilterExpression child) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index bdf1af317d..9685b6625c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,14 +1,17 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the constant null, resulting from text such as: equals(lastName,null) +/// Represents the constant null, resulting from the text: null. /// [PublicAPI] public class NullConstantExpression : IdentifierExpression { + /// + /// Provides access to the singleton instance. + /// public static readonly NullConstantExpression Instance = new(); private NullConstantExpression() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index 88846f3708..184fd7a3c1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -3,18 +3,35 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: 1, or: +/// +/// articles:2 +/// +/// . /// [PublicAPI] public class PaginationElementQueryStringValueExpression : QueryExpression { + /// + /// The relationship this pagination applies to. Chain format: zero or more relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression? Scope { get; } + + /// + /// The numeric pagination value. + /// public int Value { get; } - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) + /// + /// The zero-based position in the text of the query string parameter value. + /// + public int Position { get; } + + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value, int position) { Scope = scope; Value = value; + Position = position; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -24,12 +41,12 @@ public override TResult Accept(QueryExpressionVisitor + /// The one-based page number. + /// public PageNumber PageNumber { get; } + + /// + /// The optional page size. + /// public PageSize? PageSize { get; } public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index a65e9c0a15..f16ca0cbd6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -4,11 +4,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents pagination in a query string, resulting from text such as: 1,articles:2 +/// Represents pagination in a query string, resulting from text such as: +/// +/// 1,articles:2 +/// +/// . /// [PublicAPI] public class PaginationQueryStringValueExpression : QueryExpression { + /// + /// The list of one or more pagination elements. + /// public IImmutableList Elements { get; } public PaginationQueryStringValueExpression(IImmutableList elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index 2ff93dafe4..dac7493109 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// Represents the base data structure for immutable types that query string parameters are converted into. This intermediate structure is later -/// transformed into system trees that are handled by Entity Framework Core. +/// transformed into System.Linq trees that are handled by Entity Framework Core. /// public abstract class QueryExpression { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 7051e81f73..a8e87f5db6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. +/// Building block for rewriting trees. It walks through nested expressions and updates the parent on changes. /// [PublicAPI] public class QueryExpressionRewriter : QueryExpressionVisitor @@ -105,25 +105,11 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { - SortElementExpression? newExpression = null; + QueryExpression? newTarget = Visit(expression.Target, argument); - if (expression.Count != null) - { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } - } - else if (expression.TargetAttribute != null) - { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } - } - - if (newExpression != null) + if (newTarget != null) { + var newExpression = new SortElementExpression(newTarget, expression.IsAscending); return newExpression.Equals(expression) ? expression : newExpression; } @@ -240,7 +226,7 @@ public override QueryExpression PaginationElementQueryStringValue(PaginationElem { ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value, expression.Position); return newExpression.Equals(expression) ? expression : newExpression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index bc2d018033..9bc17fd3fa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -3,12 +3,28 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... +/// Represents the relationship scope of a query string parameter, resulting from text such as: +/// +/// ?sort[articles] +/// +/// , or: +/// +/// ?filter[author.articles.comments] +/// +/// . /// [PublicAPI] public class QueryStringParameterScopeExpression : QueryExpression { + /// + /// The name of the query string parameter, without its surrounding brackets. + /// public LiteralConstantExpression ParameterName { get; } + + /// + /// The scope this parameter value applies to, or null for the URL endpoint scope. Chain format for the filter/sort parameters: an optional list + /// of relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression? Scope { get; } public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 9224642133..993e11f4f4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -1,15 +1,27 @@ using System.Collections.Immutable; using JetBrains.Annotations; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author +/// Represents a chain of JSON:API fields (relationships and attributes), resulting from text such as: +/// +/// articles.revisions.author +/// +/// , or: +/// +/// owner.LastName +/// +/// . /// [PublicAPI] public class ResourceFieldChainExpression : IdentifierExpression { + /// + /// A list of one or more JSON:API fields. Use to convert from text. + /// public IImmutableList Fields { get; } public ResourceFieldChainExpression(ResourceFieldAttribute field) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index bfdf30e8d5..154c44d159 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -4,28 +4,34 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: lastName, +/// +/// -lastModifiedAt +/// +/// , or: +/// +/// count(children) +/// +/// . /// [PublicAPI] public class SortElementExpression : QueryExpression { - public ResourceFieldChainExpression? TargetAttribute { get; } - public CountExpression? Count { get; } + /// + /// The target to sort on, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public QueryExpression Target { get; } + + /// + /// Indicates the sort direction. + /// public bool IsAscending { get; } - public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) + public SortElementExpression(QueryExpression target, bool isAscending) { - ArgumentGuard.NotNull(targetAttribute); + ArgumentGuard.NotNull(target); - TargetAttribute = targetAttribute; - IsAscending = isAscending; - } - - public SortElementExpression(CountExpression count, bool isAscending) - { - ArgumentGuard.NotNull(count); - - Count = count; + Target = target; IsAscending = isAscending; } @@ -53,14 +59,7 @@ private string InnerToString(bool toFullString) builder.Append('-'); } - if (TargetAttribute != null) - { - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); - } - else if (Count != null) - { - builder.Append(toFullString ? Count.ToFullString() : Count); - } + builder.Append(toFullString ? Target.ToFullString() : Target); return builder.ToString(); } @@ -79,11 +78,11 @@ public override bool Equals(object? obj) var other = (SortElementExpression)obj; - return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + return Equals(Target, other.Target) && IsAscending == other.IsAscending; } public override int GetHashCode() { - return HashCode.Combine(TargetAttribute, Count, IsAscending); + return HashCode.Combine(Target, IsAscending); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 53b067d4e8..9c63e46013 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -4,11 +4,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt +/// Represents a sorting, resulting from text such as: +/// +/// lastName,-lastModifiedAt,count(children) +/// +/// . /// [PublicAPI] public class SortExpression : QueryExpression { + /// + /// One or more elements to sort on. + /// public IImmutableList Elements { get; } public SortExpression(IImmutableList elements) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index f36427b2e1..e075c3f915 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -5,11 +5,18 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles +/// Represents a sparse fieldset, resulting from text such as: +/// +/// firstName,lastName,articles +/// +/// . /// [PublicAPI] public class SparseFieldSetExpression : QueryExpression { + /// + /// The set of JSON:API fields to include. Chain format: a single field. + /// public IImmutableSet Fields { get; } public SparseFieldSetExpression(IImmutableSet fields) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index c69be71292..fc1e9fb88b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -11,6 +11,9 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class SparseFieldTableExpression : QueryExpression { + /// + /// The set of JSON:API fields to include, per resource type. + /// public IImmutableDictionary Table { get; } public SparseFieldTableExpression(IImmutableDictionary table) diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs similarity index 94% rename from src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs rename to src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs index 93b85c090e..bbc76a8269 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition diff --git a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs similarity index 97% rename from src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs rename to src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs index 32a4724637..22046d3bca 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Takes sparse fieldsets from s and invokes diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs deleted file mode 100644 index 4b779d1ccd..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Indicates how to handle derived types when resolving resource field chains. -/// -internal enum FieldChainInheritanceRequirement -{ - /// - /// Do not consider derived types when resolving attributes or relationships. - /// - Disabled, - - /// - /// Consider derived types when resolving attributes or relationships, but fail when multiple matches are found. - /// - RequireSingleMatch -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs deleted file mode 100644 index 58ab6f0830..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Used internally when parsing subexpressions in the query string parsers to indicate requirements when resolving a chain of fields. Note these may be -/// interpreted differently or even discarded completely by the various parser implementations, as they tend to better understand the characteristics of -/// the entire expression being parsed. -/// -[Flags] -public enum FieldChainRequirements -{ - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInAttribute = 1, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToOne = 2, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToMany = 4, - - /// - /// Indicates one or a chain of s. - /// - IsRelationship = EndsInToOne | EndsInToMany -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs deleted file mode 100644 index 541b50a220..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ /dev/null @@ -1,476 +0,0 @@ -using System.Collections.Immutable; -using Humanizer; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class FilterParser : QueryExpressionParser -{ - private readonly IResourceFactory _resourceFactory; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) - { - ArgumentGuard.NotNull(resourceFactory); - - _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public FilterExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - return InScopeOfResourceType(resourceTypeInScope, () => - { - Tokenize(source); - - FilterExpression expression = ParseFilter(); - - AssertTokenStackIsEmpty(); - - return expression; - }); - } - - protected FilterExpression ParseFilter() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) - { - switch (nextToken.Value) - { - case Keywords.Not: - { - return ParseNot(); - } - case Keywords.And: - case Keywords.Or: - { - return ParseLogical(nextToken.Value); - } - case Keywords.Equals: - case Keywords.LessThan: - case Keywords.LessOrEqual: - case Keywords.GreaterThan: - case Keywords.GreaterOrEqual: - { - return ParseComparison(nextToken.Value); - } - case Keywords.Contains: - case Keywords.StartsWith: - case Keywords.EndsWith: - { - return ParseTextMatch(nextToken.Value); - } - case Keywords.Any: - { - return ParseAny(); - } - case Keywords.Has: - { - return ParseHas(); - } - case Keywords.IsType: - { - return ParseIsType(); - } - } - } - - throw new QueryParseException("Filter function expected."); - } - - protected NotExpression ParseNot() - { - EatText(Keywords.Not); - EatSingleCharacterToken(TokenKind.OpenParen); - - FilterExpression child = ParseFilter(); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new NotExpression(child); - } - - protected LogicalExpression ParseLogical(string operatorName) - { - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); - - FilterExpression term = ParseFilter(); - termsBuilder.Add(term); - - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - var logicalOperator = Enum.Parse(operatorName.Pascalize()); - return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); - } - - protected ComparisonExpression ParseComparison(string operatorName) - { - var comparisonOperator = Enum.Parse(operatorName.Pascalize()); - - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - // Allow equality comparison of a HasOne relationship with null. - FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals - ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne - : FieldChainRequirements.EndsInAttribute; - - QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); - Converter rightConstantValueConverter; - - if (leftTerm is CountExpression) - { - rightConstantValueConverter = GetConstantValueConverterForCount(); - } - else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) - { - rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute); - } - else - { - // This temporary value never survives; it gets discarded during the second pass below. - rightConstantValueConverter = _ => 0; - } - - EatSingleCharacterToken(TokenKind.Comma); - - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); - - EatSingleCharacterToken(TokenKind.CloseParen); - - if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to produce an error. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - - return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); - } - - protected MatchTextExpression ParseTextMatch(string matchFunctionName) - { - EatText(matchFunctionName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType; - - if (targetAttributeType != typeof(string)) - { - throw new QueryParseException("Attribute of type 'String' expected."); - } - - EatSingleCharacterToken(TokenKind.Comma); - - Converter constantValueConverter = stringValue => stringValue; - LiteralConstantExpression constant = ParseConstant(constantValueConverter); - - EatSingleCharacterToken(TokenKind.CloseParen); - - var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttributeChain, constant, matchKind); - } - - protected AnyExpression ParseAny() - { - EatText(Keywords.Any); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]); - - EatSingleCharacterToken(TokenKind.Comma); - - ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - - LiteralConstantExpression constant = ParseConstant(constantValueConverter); - constantsBuilder.Add(constant); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(constantValueConverter); - constantsBuilder.Add(constant); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - IImmutableSet constantSet = constantsBuilder.ToImmutable(); - - return new AnyExpression(targetAttribute, constantSet); - } - - protected HasExpression ParseHas() - { - EatText(Keywords.Has); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields[^1]); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new HasExpression(targetCollection, filter); - } - - private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) - { - return InScopeOfResourceType(hasManyRelationship.RightType, ParseFilter); - } - - private IsTypeExpression ParseIsType() - { - EatText(Keywords.IsType); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); - - EatSingleCharacterToken(TokenKind.Comma); - - ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : _resourceTypeInScope!; - ResourceType derivedType = ParseDerivedType(baseType); - - FilterExpression? child = TryParseFilterInIsType(derivedType); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new IsTypeExpression(targetToOneRelationship, derivedType, child); - } - - private ResourceFieldChainExpression? TryParseToOneRelationshipChain() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - return null; - } - - return ParseFieldChain(FieldChainRequirements.EndsInToOne, "Relationship name or , expected."); - } - - private ResourceType ParseDerivedType(ResourceType baseType) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - string derivedTypeName = token.Value!; - return ResolveDerivedType(baseType, derivedTypeName); - } - - throw new QueryParseException("Resource type expected."); - } - - private ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName) - { - ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); - - if (derivedType == null) - { - throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'."); - } - - return derivedType; - } - - private ResourceType? GetDerivedType(ResourceType baseType, string publicName) - { - foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) - { - if (derivedType.PublicName == publicName) - { - return derivedType; - } - - ResourceType? nextType = GetDerivedType(derivedType, publicName); - - if (nextType != null) - { - return nextType; - } - } - - return null; - } - - private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) - { - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = InScopeOfResourceType(derivedType, ParseFilter); - } - - return filter; - } - - protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - return ParseFieldChain(chainRequirements, "Count function or field name expected."); - } - - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - IdentifierExpression? constantOrNull = TryParseConstantOrNull(constantValueConverter); - - if (constantOrNull != null) - { - return constantOrNull; - } - - return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); - } - - protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter) - { - if (TokenStack.TryPeek(out Token? nextToken)) - { - if (nextToken is { Kind: TokenKind.Text, Value: Keywords.Null }) - { - TokenStack.Pop(); - return NullConstantExpression.Instance; - } - - if (nextToken.Kind == TokenKind.QuotedText) - { - TokenStack.Pop(); - - object constantValue = constantValueConverter(nextToken.Value!); - return new LiteralConstantExpression(constantValue, nextToken.Value!); - } - } - - return null; - } - - protected LiteralConstantExpression ParseConstant(Converter constantValueConverter) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) - { - object constantValue = constantValueConverter(token.Value!); - return new LiteralConstantExpression(constantValue, token.Value!); - } - - throw new QueryParseException("Value between quotes expected."); - } - - private Converter GetConstantValueConverterForCount() - { - return stringValue => ConvertStringToType(stringValue, typeof(int)); - } - - private object ConvertStringToType(string value, Type type) - { - try - { - return RuntimeTypeConverter.ConvertType(value, type)!; - } - catch (FormatException) - { - throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'."); - } - } - - private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) - { - return stringValue => attribute.Property.Name == nameof(Identifiable.Id) - ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) - : ConvertStringToType(stringValue, attribute.Property.PropertyType); - } - - private object DeObfuscateStringId(Type resourceClrType, string stringId) - { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); - tempResource.StringId = stringId; - return tempResource.GetTypedId(); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInToOne) - { - return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - - private TResult InScopeOfResourceType(ResourceType resourceType, Func action) - { - ResourceType? backupType = _resourceTypeInScope; - - try - { - _resourceTypeInScope = resourceType; - return action(); - } - finally - { - _resourceTypeInScope = backupType; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs deleted file mode 100644 index 27466e3b0a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// The base class for parsing query string parameters, using the Recursive Descent algorithm. -/// -/// -/// Uses a tokenizer to populate a stack of tokens, which is then manipulated from the various parsing routines for subexpressions. Implementations -/// should throw on invalid input. -/// -[PublicAPI] -public abstract class QueryExpressionParser -{ - protected Stack TokenStack { get; private set; } = null!; - private protected ResourceFieldChainResolver ChainResolver { get; } = new(); - - /// - /// Takes a dotted path and walks the resource graph to produce a chain of fields. - /// - protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); - - protected virtual void Tokenize(string source) - { - var tokenizer = new QueryTokenizer(source); - TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); - } - - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) - { - var pathBuilder = new StringBuilder(); - EatFieldChain(pathBuilder, alternativeErrorMessage); - - IImmutableList chain = OnResolveFieldChain(pathBuilder.ToString(), chainRequirements); - - if (chain.Any()) - { - return new ResourceFieldChainExpression(chain); - } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - - private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMessage) - { - while (true) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - pathBuilder.Append(token.Value); - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) - { - EatSingleCharacterToken(TokenKind.Period); - pathBuilder.Append('.'); - } - else - { - return; - } - } - else - { - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - } - } - - protected CountExpression? TryParseCount() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count }) - { - TokenStack.Pop(); - - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new CountExpression(targetCollection); - } - - return null; - } - - protected void EatText(string text) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) - { - throw new QueryParseException($"{text} expected."); - } - } - - protected void EatSingleCharacterToken(TokenKind kind) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) - { - char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; - throw new QueryParseException($"{ch} expected."); - } - } - - protected void AssertTokenStackIsEmpty() - { - if (TokenStack.Any()) - { - throw new QueryParseException("End of expression expected."); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs deleted file mode 100644 index 2265ca56da..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class QueryParseException : Exception -{ - public QueryParseException(string message) - : base(message) - { - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs deleted file mode 100644 index ef95b3ed92..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class QueryStringParameterScopeParser : QueryExpressionParser -{ - private readonly FieldChainRequirements _chainRequirements; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action? validateSingleFieldCallback = null) - { - _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } - - var name = new LiteralConstantExpression(token.Value!); - - ResourceFieldChainExpression? scope = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) - { - TokenStack.Pop(); - - scope = ParseFieldChain(_chainRequirements, null); - - EatSingleCharacterToken(TokenKind.CloseBracket); - } - - return new QueryStringParameterScopeExpression(name, scope); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.IsRelationship) - { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs deleted file mode 100644 index 6630cf2767..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal enum ResourceFieldCategory -{ - Field, - Attribute, - Relationship -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs deleted file mode 100644 index e15b14893a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal sealed class ResourceFieldChainErrorFormatter -{ - public string GetForNotFound(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, - FieldChainInheritanceRequirement inheritanceRequirement) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" does not exist on resource type '{resourceType.PublicName}'"); - - if (inheritanceRequirement != FieldChainInheritanceRequirement.Disabled && resourceType.DirectlyDerivedTypes.Any()) - { - builder.Append(" or any of its derived types"); - } - - builder.Append('.'); - - return builder.ToString(); - } - - public string GetForMultipleMatches(ResourceFieldCategory category, string publicName, string path) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append(" is defined on multiple derived types."); - - return builder.ToString(); - } - - public string GetForWrongFieldType(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, string expected) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" must be {expected} on resource type '{resourceType.PublicName}'."); - - return builder.ToString(); - } - - public string GetForNoneFound(ResourceFieldCategory category, string publicName, string path, ICollection parentResourceTypes, - bool hasDerivedTypes) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - if (parentResourceTypes.Count == 1) - { - builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); - } - else - { - string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); - builder.Append($" does not exist on any of the resource types {typeNames}"); - } - - builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); - - return builder.ToString(); - } - - private static void WriteSource(ResourceFieldCategory category, string publicName, StringBuilder builder) - { - builder.Append($"{category} '{publicName}'"); - } - - private static void WritePath(string path, string publicName, StringBuilder builder) - { - if (path != publicName) - { - builder.Append($" in '{path}'"); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs deleted file mode 100644 index 4fb2632557..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Provides helper methods to resolve a chain of fields (relationships and attributes) from the resource graph. -/// -internal sealed class ResourceFieldChainResolver -{ - private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); - - /// - /// Resolves a chain of to-one relationships. - /// author - /// - /// author.address.country - /// - /// - public IImmutableList ResolveToOneChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments - /// - public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); - - chainBuilder.Add(lastToManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of relationships. - /// - /// blogs.articles.comments - /// - /// - /// author.address - /// - /// - /// articles.revisions.author - /// - /// - public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in an attribute. - /// - /// author.address.country.name - /// - /// name - /// - public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(lastAttribute, nextResourceType, path); - - chainBuilder.Add(lastAttribute); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in a to-many relationship. - /// - /// article.comments - /// - /// - /// comments - /// - /// - public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toManyRelationship, nextResourceType, path); - - chainBuilder.Add(toManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in either an attribute or a to-one relationship. - /// - /// author.address.country.name - /// - /// - /// author.address - /// - /// - public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceType, path); - - if (lastField is HasManyAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Field, lastName, path, nextResourceType, - "an attribute or a to-one relationship"); - - throw new QueryParseException(message); - } - - validateCallback?.Invoke(lastField, nextResourceType, path); - - chainBuilder.Add(lastField); - return chainBuilder.ToImmutable(); - } - - private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection relationships = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindRelationshipByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetRelationshipsInTypeOrDerived(publicName); - - if (relationships.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Relationship, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && relationships.Count > 1) - { - string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Relationship, publicName, path); - throw new QueryParseException(message); - } - - return relationships.First(); - } - - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasManyAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-many relationship"); - throw new QueryParseException(message); - } - - return relationship; - } - - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasOneAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-one relationship"); - throw new QueryParseException(message); - } - - return relationship; - } - - private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path, FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection attributes = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindAttributeByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetAttributesInTypeOrDerived(publicName); - - if (attributes.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Attribute, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && attributes.Count > 1) - { - string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Attribute, publicName, path); - throw new QueryParseException(message); - } - - return attributes.First(); - } - - public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) - { - ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); - - if (field == null) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Field, publicName, path, resourceType, - FieldChainInheritanceRequirement.Disabled); - - throw new QueryParseException(message); - } - - return field; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs deleted file mode 100644 index 7f4a142ef0..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class SortParser : QueryExpressionParser -{ - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public SortParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SortExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - SortExpression expression = ParseSort(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SortExpression ParseSort() - { - SortElementExpression firstElement = ParseSortElement(); - - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - elementsBuilder.Add(firstElement); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - SortElementExpression nextElement = ParseSortElement(); - elementsBuilder.Add(nextElement); - } - - return new SortExpression(elementsBuilder.ToImmutable()); - } - - protected SortElementExpression ParseSortElement() - { - bool isAscending = true; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - isAscending = false; - } - - CountExpression? count = TryParseCount(); - - if (count != null) - { - return new SortElementExpression(count, isAscending); - } - - string errorMessage = isAscending ? "-, count function or field name expected." : "Count function or field name expected."; - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, errorMessage); - return new SortElementExpression(targetAttribute, isAscending); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - // An attribute or relationship name usually matches a single field, even when overridden in derived types. - // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints: - // - // public abstract class ShoppingBasket : Identifiable - // { - // } - // - // public sealed class SilverShoppingBasket : ShoppingBasket - // { - // [Attr] - // public short BonusPoints { get; set; } - // } - // - // public sealed class PlatinumShoppingBasket : ShoppingBasket - // { - // [Attr] - // public long BonusPoints { get; set; } - // } - // - // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends - // on which attribute is used. - // - // Because there is no syntax to pick one, we fail with an error. We could add such optional upcast syntax - // (which would be required in this case) in the future to make it work, if desired. - - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch, - _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs deleted file mode 100644 index bff295acae..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class Token -{ - public TokenKind Kind { get; } - public string? Value { get; } - - public Token(TokenKind kind) - { - Kind = kind; - } - - public Token(TokenKind kind, string value) - : this(kind) - { - Value = value; - } - - public override string ToString() - { - return Value == null ? Kind.ToString() : $"{Kind}: {Value}"; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs deleted file mode 100644 index 32691e05ab..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Humanizer; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Produces unique names for lambda parameters. -/// -[PublicAPI] -public sealed class LambdaParameterNameFactory -{ - private readonly HashSet _namesInScope = new(); - - public LambdaParameterNameScope Create(string typeName) - { - ArgumentGuard.NotNullNorEmpty(typeName); - - string parameterName = typeName.Camelize(); - parameterName = EnsureNameIsUnique(parameterName); - - _namesInScope.Add(parameterName); - return new LambdaParameterNameScope(parameterName, this); - } - - private string EnsureNameIsUnique(string name) - { - if (!_namesInScope.Contains(name)) - { - return name; - } - - int counter = 1; - string alternativeName; - - do - { - counter++; - alternativeName = name + counter; - } - while (_namesInScope.Contains(alternativeName)); - - return alternativeName; - } - - public void Release(string parameterName) - { - _namesInScope.Remove(parameterName); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs deleted file mode 100644 index 031dae0a0f..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaParameterNameScope : IDisposable -{ - private readonly LambdaParameterNameFactory _owner; - - public string Name { get; } - - public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) - { - ArgumentGuard.NotNullNorEmpty(name); - ArgumentGuard.NotNull(owner); - - Name = name; - _owner = owner; - } - - public void Dispose() - { - _owner.Release(Name); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs deleted file mode 100644 index 52caddbe62..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". -/// -[PublicAPI] -public sealed class LambdaScope : IDisposable -{ - private readonly LambdaParameterNameScope _parameterNameScope; - - public ParameterExpression Parameter { get; } - public Expression Accessor { get; } - - private LambdaScope(LambdaParameterNameScope parameterNameScope, ParameterExpression parameter, Expression accessor) - { - _parameterNameScope = parameterNameScope; - Parameter = parameter; - Accessor = accessor; - } - - public static LambdaScope Create(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) - { - ArgumentGuard.NotNull(nameFactory); - ArgumentGuard.NotNull(elementType); - - LambdaParameterNameScope parameterNameScope = nameFactory.Create(elementType.Name); - ParameterExpression parameter = Expression.Parameter(elementType, parameterNameScope.Name); - Expression accessor = accessorExpression ?? parameter; - - return new LambdaScope(parameterNameScope, parameter, accessor); - } - - public LambdaScope WithAccessor(Expression accessorExpression) - { - ArgumentGuard.NotNull(accessorExpression); - - return new LambdaScope(_parameterNameScope, Parameter, accessorExpression); - } - - public void Dispose() - { - _parameterNameScope.Dispose(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs deleted file mode 100644 index 6e4955cf40..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaScopeFactory -{ - private readonly LambdaParameterNameFactory _nameFactory; - - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) - { - ArgumentGuard.NotNull(nameFactory); - - _nameFactory = nameFactory; - } - - public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) - { - ArgumentGuard.NotNull(elementType); - - return LambdaScope.Create(_nameFactory, elementType, accessorExpression); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs deleted file mode 100644 index 775893adcc..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into -/// calls. -/// -[PublicAPI] -public class OrderClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplyOrderBy(SortExpression expression) - { - ArgumentGuard.NotNull(expression); - - return Visit(expression, null); - } - - public override Expression VisitSort(SortExpression expression, Expression? argument) - { - Expression? sortExpression = null; - - foreach (SortElementExpression sortElement in expression.Elements) - { - sortExpression = Visit(sortElement, sortExpression); - } - - return sortExpression!; - } - - public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) - { - Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); - - LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); - - string operationName = GetOperationName(previousExpression != null, expression.IsAscending); - - return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); - } - - private static string GetOperationName(bool hasPrecedingSort, bool isAscending) - { - if (hasPrecedingSort) - { - return isAscending ? "ThenBy" : "ThenByDescending"; - } - - return isAscending ? "OrderBy" : "OrderByDescending"; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector) - { - Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); - return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs deleted file mode 100644 index a497846285..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Drives conversion from into system trees. -/// -[PublicAPI] -public class QueryableBuilder -{ - private readonly Expression _source; - private readonly Type _elementType; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - private readonly IResourceFactory _resourceFactory; - private readonly IModel _entityModel; - private readonly LambdaScopeFactory _lambdaScopeFactory; - - public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, IResourceFactory resourceFactory, - IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(elementType); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); - ArgumentGuard.NotNull(resourceFactory); - ArgumentGuard.NotNull(entityModel); - - _source = source; - _elementType = elementType; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _entityModel = entityModel; - _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); - } - - public virtual Expression ApplyQuery(QueryLayer layer) - { - ArgumentGuard.NotNull(layer); - - Expression expression = _source; - - if (layer.Include != null) - { - expression = ApplyInclude(expression, layer.Include, layer.ResourceType); - } - - if (layer.Filter != null) - { - expression = ApplyFilter(expression, layer.Filter); - } - - if (layer.Sort != null) - { - expression = ApplySort(expression, layer.Sort); - } - - if (layer.Pagination != null) - { - expression = ApplyPagination(expression, layer.Pagination); - } - - if (layer.Selection is { IsEmpty: false }) - { - expression = ApplySelection(expression, layer.Selection, layer.ResourceType); - } - - return expression; - } - - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); - return builder.ApplyInclude(include); - } - - protected virtual Expression ApplyFilter(Expression source, FilterExpression filter) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory); - return builder.ApplyWhere(filter); - } - - protected virtual Expression ApplySort(Expression source, SortExpression sort) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplyOrderBy(sort); - } - - protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplySkipTake(pagination); - } - - protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory); - return builder.ApplySelect(selection, resourceType); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs deleted file mode 100644 index 90109dbfec..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into and -/// calls. -/// -[PublicAPI] -public class SkipTakeClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplySkipTake(PaginationExpression expression) - { - ArgumentGuard.NotNull(expression); - - return Visit(expression, null); - } - - public override Expression VisitPagination(PaginationExpression expression, object? argument) - { - Expression skipTakeExpression = _source; - - if (expression.PageSize != null) - { - int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; - - if (skipValue > 0) - { - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); - } - - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); - } - - return skipTakeExpression; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, int value) - { - Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); - - return Expression.Call(_extensionType, operationName, LambdaScope.Parameter.Type.AsArray(), source, constant); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs new file mode 100644 index 0000000000..1af9656154 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -0,0 +1,602 @@ +using System.Collections.Immutable; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class FilterParser : QueryExpressionParser, IFilterParser +{ + private static readonly HashSet FilterKeywords = new(new[] + { + Keywords.Not, + Keywords.And, + Keywords.Or, + Keywords.Equals, + Keywords.GreaterThan, + Keywords.GreaterOrEqual, + Keywords.LessThan, + Keywords.LessOrEqual, + Keywords.Contains, + Keywords.StartsWith, + Keywords.EndsWith, + Keywords.Any, + Keywords.Count, + Keywords.Has, + Keywords.IsType + }); + + private readonly IResourceFactory _resourceFactory; + private readonly Stack _resourceTypeStack = new(); + + /// + /// Gets the resource type currently in scope. Call to temporarily change the current resource type. + /// + protected ResourceType ResourceTypeInScope + { + get + { + if (_resourceTypeStack.Count == 0) + { + throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + } + + return _resourceTypeStack.Peek(); + } + } + + public FilterParser(IResourceFactory resourceFactory) + { + ArgumentGuard.NotNull(resourceFactory); + + _resourceFactory = resourceFactory; + } + + /// + public FilterExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + Tokenize(source); + + _resourceTypeStack.Clear(); + FilterExpression expression; + + using (InScopeOfResourceType(resourceType)) + { + expression = ParseFilter(); + + AssertTokenStackIsEmpty(); + } + + AssertResourceTypeStackIsEmpty(); + + return expression; + } + + protected virtual bool IsFunction(string name) + { + ArgumentGuard.NotNullNorEmpty(name); + + return name == Keywords.Count || FilterKeywords.Contains(name); + } + + protected virtual FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(); + } + } + } + + return ParseFilter(); + } + + private CountExpression ParseCount() + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected virtual FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Not: + { + return ParseNot(); + } + case Keywords.And: + case Keywords.Or: + { + return ParseLogical(nextToken.Value); + } + case Keywords.Equals: + case Keywords.LessThan: + case Keywords.LessOrEqual: + case Keywords.GreaterThan: + case Keywords.GreaterOrEqual: + { + return ParseComparison(nextToken.Value); + } + case Keywords.Contains: + case Keywords.StartsWith: + case Keywords.EndsWith: + { + return ParseTextMatch(nextToken.Value); + } + case Keywords.Any: + { + return ParseAny(); + } + case Keywords.Has: + { + return ParseHas(); + } + case Keywords.IsType: + { + return ParseIsType(); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Filter function expected.", position); + } + + protected virtual NotExpression ParseNot() + { + EatText(Keywords.Not); + EatSingleCharacterToken(TokenKind.OpenParen); + + FilterExpression child = ParseFilter(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new NotExpression(child); + } + + protected virtual LogicalExpression ParseLogical(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); + + FilterExpression term = ParseFilter(); + termsBuilder.Add(term); + + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + var logicalOperator = Enum.Parse(operatorName.Pascalize()); + return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); + } + + protected virtual ComparisonExpression ParseComparison(string operatorName) + { + var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + QueryExpression leftTerm = ParseComparisonLeftTerm(comparisonOperator); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); + } + + private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + // Allow equality comparison of a to-one relationship with null. + FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals + ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne + : BuiltInPatterns.ToOneChainEndingInAttribute; + + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + } + + private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) + { + if (leftTerm is ResourceFieldChainExpression leftFieldChain) + { + ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; + + if (leftLastField is HasOneAttribute) + { + return ParseNull(); + } + + var leftAttribute = (AttrAttribute)leftLastField; + + Func constantValueConverter = GetConstantValueConverterForAttribute(leftAttribute); + return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter); + } + + if (leftTerm is FunctionExpression leftFunction) + { + Func constantValueConverter = GetConstantValueConverterForType(leftFunction.ReturnType); + return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter); + } + + throw new InvalidOperationException( + $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + } + + private QueryExpression ParseTypedComparisonRightTerm(Type leftType, Func constantValueConverter) + { + bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); + + string errorMessage = + allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; + + if (TokenStack.TryPeek(out Token? nextToken)) + { + if (nextToken is { Kind: TokenKind.QuotedText }) + { + TokenStack.Pop(); + + object constantValue = constantValueConverter(nextToken.Value!, nextToken.Position); + return new LiteralConstantExpression(constantValue, nextToken.Value!); + } + + if (nextToken.Kind == TokenKind.Text) + { + if (nextToken.Value == Keywords.Null) + { + if (!allowNull) + { + throw new QueryParseException(errorMessage, nextToken.Position); + } + + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(errorMessage, position); + } + + protected virtual MatchTextExpression ParseTextMatch(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + if (targetAttribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.Comma); + + Func constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + + EatSingleCharacterToken(TokenKind.CloseParen); + + var matchKind = Enum.Parse(operatorName.Pascalize()); + return new MatchTextExpression(targetAttributeChain, constant, matchKind); + } + + protected virtual AnyExpression ParseAny() + { + EatText(Keywords.Any); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + EatSingleCharacterToken(TokenKind.Comma); + + ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + + Func constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + IImmutableSet constantSet = constantsBuilder.ToImmutable(); + + return new AnyExpression(targetAttributeChain, constantSet); + } + + protected virtual HasExpression ParseHas() + { + EatText(Keywords.Has); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1]; + + using (InScopeOfResourceType(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new HasExpression(targetCollection, filter); + } + + protected virtual IsTypeExpression ParseIsType() + { + EatText(Keywords.IsType); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + + EatSingleCharacterToken(TokenKind.Comma); + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope; + ResourceType derivedType = ParseDerivedType(baseType); + + FilterExpression? child = TryParseFilterInIsType(derivedType); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsTypeExpression(targetToOneRelationship, derivedType, child); + } + + private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + return null; + } + + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + } + + private ResourceType ParseDerivedType(ResourceType baseType) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + string derivedTypeName = token.Value!; + return ResolveDerivedType(baseType, derivedTypeName, token.Position); + } + + throw new QueryParseException("Resource type expected.", position); + } + + private static ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName, int position) + { + ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); + + if (derivedType == null) + { + throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'.", position); + } + + return derivedType; + } + + private static ResourceType? GetDerivedType(ResourceType baseType, string publicName) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + if (derivedType.PublicName == publicName) + { + return derivedType; + } + + ResourceType? nextType = GetDerivedType(derivedType, publicName); + + if (nextType != null) + { + return nextType; + } + } + + return null; + } + + private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) + { + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + using (InScopeOfResourceType(derivedType)) + { + filter = ParseFilter(); + } + } + + return filter; + } + + private LiteralConstantExpression ParseConstant(Func constantValueConverter) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + object constantValue = constantValueConverter(token.Value!, token.Position); + return new LiteralConstantExpression(constantValue, token.Value!); + } + + throw new QueryParseException("Value between quotes expected.", position); + } + + private NullConstantExpression ParseNull() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null }) + { + return NullConstantExpression.Instance; + } + + throw new QueryParseException("null expected.", position); + } + + private static Func GetConstantValueConverterForType(Type destinationType) + { + return (stringValue, position) => + { + try + { + return RuntimeTypeConverter.ConvertType(stringValue, destinationType)!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationType.Name}'.", position, exception); + } + }; + } + + private Func GetConstantValueConverterForAttribute(AttrAttribute attribute) + { + if (attribute is { Property.Name: nameof(Identifiable.Id) }) + { + return (stringValue, position) => + { + try + { + return DeObfuscateStringId(attribute.Type, stringValue); + } + catch (JsonApiException exception) + { + throw new QueryParseException(exception.Errors[0].Detail!, position); + } + }; + } + + return GetConstantValueConverterForType(attribute.Property.PropertyType); + } + + private object DeObfuscateStringId(ResourceType resourceType, string stringId) + { + IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceType.ClrType); + tempResource.StringId = stringId; + return tempResource.GetTypedId(); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + if (field.IsFilterBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Filtering on {kind} '{field.PublicName}' is not allowed.", position); + } + } + + /// + /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed. + /// + protected IDisposable InScopeOfResourceType(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + _resourceTypeStack.Push(resourceType); + return new PopResourceTypeOnDispose(_resourceTypeStack); + } + + private void AssertResourceTypeStackIsEmpty() + { + if (_resourceTypeStack.Count > 0) + { + throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); + } + } + + private sealed class PopResourceTypeOnDispose : IDisposable + { + private readonly Stack _resourceTypeStack; + + public PopResourceTypeOnDispose(Stack resourceTypeStack) + { + _resourceTypeStack = resourceTypeStack; + } + + public void Dispose() + { + _resourceTypeStack.Pop(); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs new file mode 100644 index 0000000000..289a745027 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'filter' query string parameter value. +/// +public interface IFilterParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + FilterExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs new file mode 100644 index 0000000000..2524d1ef4c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'include' query string parameter value. +/// +public interface IIncludeParser +{ + /// + /// Parses the specified source into an . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + IncludeExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs new file mode 100644 index 0000000000..bd15ac2b3c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'page' query string parameter value. +/// +public interface IPaginationParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// Due to the syntax of the JSON:API pagination parameter, The returned is an intermediate value + /// that gets converted into by . + /// + PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..22cd2b1426 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' and 'filter' query string parameter names, which contain a resource field chain that indicates the scope its query string +/// parameter value applies to. +/// +public interface IQueryStringParameterScopeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// The pattern that the field chain in must match. + /// + /// + /// The match options for . + /// + QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs new file mode 100644 index 0000000000..be5f72545f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' query string parameter value. +/// +public interface ISortParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SortExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs new file mode 100644 index 0000000000..acec82b8f2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter value. +/// +public interface ISparseFieldSetParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SparseFieldSetExpression? Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs new file mode 100644 index 0000000000..fd5cc0aec5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter name. +/// +public interface ISparseFieldTypeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + ResourceType Parse(string source); +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs similarity index 70% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index 1250e36312..27fffb9467 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -6,30 +6,39 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class IncludeParser : QueryExpressionParser +public class IncludeParser : QueryExpressionParser, IIncludeParser { - private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); + private readonly IJsonApiOptions _options; - public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) + public IncludeParser(IJsonApiOptions options) { - ArgumentGuard.NotNull(resourceTypeInScope); + ArgumentGuard.NotNull(options); + + _options = options; + } + + /// + public IncludeExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); Tokenize(source); - IncludeExpression expression = ParseInclude(resourceTypeInScope, maximumDepth); + IncludeExpression expression = ParseInclude(source, resourceType); AssertTokenStackIsEmpty(); - ValidateMaximumIncludeDepth(maximumDepth, expression); + ValidateMaximumIncludeDepth(expression, 0); return expression; } - protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth) + protected virtual IncludeExpression ParseInclude(string source, ResourceType resourceType) { - var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope); + var treeRoot = IncludeTreeNode.CreateRoot(resourceType); bool isAtStart = true; while (TokenStack.Any()) @@ -43,13 +52,13 @@ protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? isAtStart = false; } - ParseRelationshipChain(treeRoot); + ParseRelationshipChain(source, treeRoot); } return treeRoot.ToExpression(); } - private void ParseRelationshipChain(IncludeTreeNode treeRoot) + private void ParseRelationshipChain(string source, IncludeTreeNode treeRoot) { // A relationship name usually matches a single relationship, even when overridden in derived types. // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: @@ -77,27 +86,30 @@ private void ParseRelationshipChain(IncludeTreeNode treeRoot) // that there's currently no way to include Products without Articles. We could add such optional upcast syntax // in the future, if desired. - ICollection children = ParseRelationshipName(treeRoot.AsList()); + ICollection children = ParseRelationshipName(source, treeRoot.AsList()); while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) { EatSingleCharacterToken(TokenKind.Period); - children = ParseRelationshipName(children); + children = ParseRelationshipName(source, children); } } - private ICollection ParseRelationshipName(ICollection parents) + private ICollection ParseRelationshipName(string source, ICollection parents) { + int position = GetNextTokenPositionOrEnd(); + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - return LookupRelationshipName(token.Value!, parents); + return LookupRelationshipName(token.Value!, parents, source, position); } - throw new QueryParseException("Relationship name expected."); + throw new QueryParseException("Relationship name expected.", position); } - private ICollection LookupRelationshipName(string relationshipName, ICollection parents) + private static ICollection LookupRelationshipName(string relationshipName, ICollection parents, string source, + int position) { List children = new(); HashSet relationshipsFound = new(); @@ -118,116 +130,109 @@ private ICollection LookupRelationshipName(string relationshipN } } - AssertRelationshipsFound(relationshipsFound, relationshipName, parents); - AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, parents); + AssertRelationshipsFound(relationshipsFound, relationshipName, parents, position); + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, source, position); return children; } - private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents) + private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents, + int position) { if (relationshipsFound.Any()) { return; } - string[] parentPaths = parents.Select(parent => parent.Path).Distinct().Where(path => path != string.Empty).ToArray(); - string path = parentPaths.Length > 0 ? $"{parentPaths[0]}.{relationshipName}" : relationshipName; - ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); - string message = ErrorFormatter.GetForNoneFound(ResourceFieldCategory.Relationship, relationshipName, path, parentResourceTypes, hasDerivedTypes); - throw new QueryParseException(message); + string message = GetErrorMessageForNoneFound(relationshipName, parentResourceTypes, hasDerivedTypes); + throw new QueryParseException(message, position); + } + + private static string GetErrorMessageForNoneFound(string relationshipName, ICollection parentResourceTypes, bool hasDerivedTypes) + { + var builder = new StringBuilder($"Relationship '{relationshipName}'"); + + if (parentResourceTypes.Count == 1) + { + builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); + } + else + { + string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); + builder.Append($" does not exist on any of the resource types {typeNames}"); + } + + builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + + return builder.ToString(); } - private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, - ICollection parents) + private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, string source, int position) { if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked())) { - string parentPath = parents.First().Path; ResourceType resourceType = relationshipsFound.First().LeftType; + string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed."; - string message = parentPath == string.Empty - ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." - : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; + var exception = new QueryParseException(message, position); + string specificMessage = exception.GetMessageWithPosition(source); - throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); + throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage); } } - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IncludeExpression include) + private void ValidateMaximumIncludeDepth(IncludeExpression include, int position) { - if (maximumDepth != null) + if (_options.MaximumIncludeDepth != null) { + int maximumDepth = _options.MaximumIncludeDepth.Value; Stack parentChain = new(); foreach (IncludeElementExpression element in include.Elements) { - ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth.Value); + ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth, position); } } } - private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth) + private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth, + int position) { parentChain.Push(includeElement.Relationship); if (parentChain.Count > maximumDepth) { string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}.", position); } foreach (IncludeElementExpression child in includeElement.Children) { - ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth); + ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth, position); } parentChain.Pop(); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } - private sealed class IncludeTreeNode { - private readonly IncludeTreeNode? _parent; private readonly IDictionary _children = new Dictionary(); public RelationshipAttribute Relationship { get; } - public string Path - { - get - { - var pathBuilder = new StringBuilder(); - IncludeTreeNode? parent = this; - - while (parent is { Relationship: not HiddenRootRelationshipAttribute }) - { - pathBuilder.Insert(0, pathBuilder.Length > 0 ? $"{parent.Relationship.PublicName}." : parent.Relationship.PublicName); - parent = parent._parent; - } - - return pathBuilder.ToString(); - } - } - - private IncludeTreeNode(RelationshipAttribute relationship, IncludeTreeNode? parent) + private IncludeTreeNode(RelationshipAttribute relationship) { Relationship = relationship; - _parent = parent; } public static IncludeTreeNode CreateRoot(ResourceType resourceType) { var relationship = new HiddenRootRelationshipAttribute(resourceType); - return new IncludeTreeNode(relationship, null); + return new IncludeTreeNode(relationship); } public ICollection EnsureChildren(ICollection relationships) @@ -236,7 +241,7 @@ public ICollection EnsureChildren(ICollection [PublicAPI] -public class PaginationParser : QueryExpressionParser +public class PaginationParser : QueryExpressionParser, IPaginationParser { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public PaginationParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) + /// + public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType) { - ArgumentGuard.NotNull(resourceTypeInScope); - - _resourceTypeInScope = resourceTypeInScope; + ArgumentGuard.NotNull(resourceType); Tokenize(source); - PaginationQueryStringValueExpression expression = ParsePagination(); + PaginationQueryStringValueExpression expression = ParsePagination(resourceType); AssertTokenStackIsEmpty(); return expression; } - protected PaginationQueryStringValueExpression ParsePagination() + protected virtual PaginationQueryStringValueExpression ParsePagination(ResourceType resourceType) { ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - PaginationElementQueryStringValueExpression element = ParsePaginationElement(); + PaginationElementQueryStringValueExpression element = ParsePaginationElement(resourceType); elementsBuilder.Add(element); while (TokenStack.Any()) { EatSingleCharacterToken(TokenKind.Comma); - element = ParsePaginationElement(); + element = ParsePaginationElement(resourceType); elementsBuilder.Add(element); } return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); } - protected PaginationElementQueryStringValueExpression ParsePaginationElement() + protected virtual PaginationElementQueryStringValueExpression ParsePaginationElement(ResourceType resourceType) { + int position = GetNextTokenPositionOrEnd(); int? number = TryParseNumber(); if (number != null) { - return new PaginationElementQueryStringValueExpression(null, number.Value); + return new PaginationElementQueryStringValueExpression(null, number.Value, position); } - ResourceFieldChainExpression scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); + ResourceFieldChainExpression scope = ParseFieldChain(BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None, resourceType, + "Number or relationship name expected."); EatSingleCharacterToken(TokenKind.Colon); + position = GetNextTokenPositionOrEnd(); number = TryParseNumber(); if (number == null) { - throw new QueryParseException("Number expected."); + throw new QueryParseException("Number expected.", position); } - return new PaginationElementQueryStringValueExpression(scope, number.Value); + return new PaginationElementQueryStringValueExpression(scope, number.Value, position); } - protected int? TryParseNumber() + private int? TryParseNumber() { if (TokenStack.TryPeek(out Token? nextToken)) { @@ -83,13 +78,14 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() if (nextToken.Kind == TokenKind.Minus) { TokenStack.Pop(); + int position = GetNextTokenPositionOrEnd(); if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) { return -number; } - throw new QueryParseException("Digits expected."); + throw new QueryParseException("Digits expected.", position); } if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) @@ -101,9 +97,4 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() return null; } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs new file mode 100644 index 0000000000..5f1d3c1e89 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -0,0 +1,185 @@ +using System.Collections.Immutable; +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The base class for parsing query string parameters, using the Recursive Descent algorithm. +/// +/// +/// A tokenizer populates a stack of tokens from the source text, which is then recursively popped by various parsing routines. A +/// is expected to be thrown on invalid input. +/// +[PublicAPI] +public abstract class QueryExpressionParser +{ + private int _endOfSourcePosition; + + /// + /// Contains the tokens produced from the source text, after has been called. + /// + /// + /// The various parsing methods typically pop tokens while producing s. + /// + protected Stack TokenStack { get; private set; } = new(); + + /// + /// Enables derived types to throw a when usage of a JSON:API field inside a field chain is not permitted. + /// + protected virtual void ValidateField(ResourceFieldAttribute field, int position) + { + } + + /// + /// Populates from the source text using . + /// + /// + /// To use a custom tokenizer, override this method and consider overriding . + /// + protected virtual void Tokenize(string source) + { + var tokenizer = new QueryTokenizer(source); + TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); + _endOfSourcePosition = source.Length; + } + + /// + /// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern. + /// + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType, + string? alternativeErrorMessage) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(resourceType); + + int startPosition = GetNextTokenPositionOrEnd(); + + string path = EatFieldChain(alternativeErrorMessage); + PatternMatchResult result = pattern.Match(path, resourceType, options); + + if (!result.IsSuccess) + { + string message = result.IsFieldChainError + ? result.FailureMessage + : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; + + throw new QueryParseException(message, startPosition + result.FailurePosition); + } + + int chainPosition = 0; + + foreach (ResourceFieldAttribute field in result.FieldChain) + { + ValidateField(field, startPosition + chainPosition); + + chainPosition += field.PublicName.Length + 1; + } + + return new ResourceFieldChainExpression(result.FieldChain.ToImmutableArray()); + } + + private string EatFieldChain(string? alternativeErrorMessage) + { + var pathBuilder = new StringBuilder(); + + while (true) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null) + { + pathBuilder.Append(token.Value); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + pathBuilder.Append('.'); + } + else + { + return pathBuilder.ToString(); + } + } + else + { + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position); + } + } + } + + /// + /// Consumes a token containing the expected text from the top of . Throws a if a different + /// token kind is at the top, it contains a different text, or if there are no more tokens available. + /// + protected void EatText(string text) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) + { + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{text} expected.", position); + } + } + + /// + /// Consumes the expected token kind from the top of . Throws a if a different token kind is + /// at the top, or if there are no more tokens available. + /// + protected virtual void EatSingleCharacterToken(TokenKind kind) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) + { + char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{ch} expected.", position); + } + } + + /// + /// Gets the zero-based position of the token at the top of , or the position at the end of the source text if there are no more + /// tokens available. + /// + protected int GetNextTokenPositionOrEnd() + { + if (TokenStack.TryPeek(out Token? nextToken)) + { + return nextToken.Position; + } + + return _endOfSourcePosition; + } + + /// + /// Gets the zero-based position of the last field in the specified resource field chain. + /// + protected int GetRelativePositionOfLastFieldInChain(ResourceFieldChainExpression fieldChain) + { + ArgumentGuard.NotNull(fieldChain); + + int position = 0; + + for (int index = 0; index < fieldChain.Fields.Count - 1; index++) + { + position += fieldChain.Fields[index].PublicName.Length + 1; + } + + return position; + } + + /// + /// Throws a when isn't empty. Derived types should call this when parsing has completed, to + /// ensure all input has been processed. + /// + protected void AssertTokenStackIsEmpty() + { + if (TokenStack.Any()) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("End of expression expected.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs new file mode 100644 index 0000000000..8136fb476e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs @@ -0,0 +1,43 @@ +using System.Text; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The error that is thrown when parsing a query string parameter fails. +/// +[PublicAPI] +public sealed class QueryParseException : Exception +{ + /// + /// Gets the zero-based position in the text of the query string parameter name/value, or at its end, where the failure occurred, or -1 if unavailable. + /// + public int Position { get; } + + public QueryParseException(string message, int position) + : base(message) + { + Position = position; + } + + public QueryParseException(string message, int position, Exception innerException) + : base(message, innerException) + { + Position = position; + } + + public string GetMessageWithPosition(string sourceText) + { + ArgumentGuard.NotNull(sourceText); + + if (Position < 0) + { + return Message; + } + + StringBuilder builder = new(); + builder.Append(Message); + builder.Append($" Failed at position {Position + 1}: {sourceText[..Position]}^{sourceText[Position..]}"); + return builder.ToString(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..2272b94caa --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -0,0 +1,52 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class QueryStringParameterScopeParser : QueryExpressionParser, IQueryStringParameterScopeParser +{ + /// + public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(pattern); + + Tokenize(source); + + QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(resourceType, pattern, options); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterScope(ResourceType resourceType, FieldChainPattern pattern, + FieldChainPatternMatchOptions options) + { + int position = GetNextTokenPositionOrEnd(); + + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected.", position); + } + + var name = new LiteralConstantExpression(token.Value!); + + ResourceFieldChainExpression? scope = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) + { + TokenStack.Pop(); + + scope = ParseFieldChain(pattern, options, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseBracket); + } + + return new QueryStringParameterScopeExpression(name, scope); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs similarity index 81% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs rename to src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs index 37f29da58d..9360d95be3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -2,7 +2,7 @@ using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public sealed class QueryTokenizer @@ -22,7 +22,8 @@ public sealed class QueryTokenizer private readonly string _source; private readonly StringBuilder _textBuffer = new(); - private int _offset; + private int _sourceOffset; + private int? _tokenStartOffset; private bool _isInQuotedSection; public QueryTokenizer(string source) @@ -36,11 +37,14 @@ public IEnumerable EnumerateTokens() { _textBuffer.Clear(); _isInQuotedSection = false; - _offset = 0; + _sourceOffset = 0; + _tokenStartOffset = null; - while (_offset < _source.Length) + while (_sourceOffset < _source.Length) { - char ch = _source[_offset]; + _tokenStartOffset ??= _sourceOffset; + + char ch = _source[_sourceOffset]; if (ch == '\'') { @@ -51,7 +55,7 @@ public IEnumerable EnumerateTokens() if (peeked == '\'') { _textBuffer.Append(ch); - _offset += 2; + _sourceOffset += 2; continue; } @@ -64,7 +68,7 @@ public IEnumerable EnumerateTokens() { if (_textBuffer.Length > 0) { - throw new QueryParseException("Unexpected ' outside text."); + throw new QueryParseException("Unexpected ' outside text.", _sourceOffset); } _isInQuotedSection = true; @@ -83,25 +87,27 @@ public IEnumerable EnumerateTokens() yield return identifierToken; } - yield return new Token(singleCharacterTokenKind.Value); + yield return new Token(singleCharacterTokenKind.Value, _sourceOffset); + + _tokenStartOffset = null; } else { if (ch == ' ' && !_isInQuotedSection) { - throw new QueryParseException("Unexpected whitespace."); + throw new QueryParseException("Unexpected whitespace.", _sourceOffset); } _textBuffer.Append(ch); } } - _offset++; + _sourceOffset++; } if (_isInQuotedSection) { - throw new QueryParseException("' expected."); + throw new QueryParseException("' expected.", _sourceOffset - 1); } Token? lastToken = ProduceTokenFromTextBuffer(false); @@ -119,7 +125,7 @@ private bool IsMinusInsideText(TokenKind kind) private char? PeekChar() { - return _offset + 1 < _source.Length ? _source[_offset + 1] : null; + return _sourceOffset + 1 < _source.Length ? _source[_sourceOffset + 1] : null; } private static TokenKind? TryGetSingleCharacterTokenKind(char ch) @@ -131,9 +137,13 @@ private bool IsMinusInsideText(TokenKind kind) { if (isQuotedText || _textBuffer.Length > 0) { + int tokenStartOffset = _tokenStartOffset!.Value; string text = _textBuffer.ToString(); + _textBuffer.Clear(); - return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text); + _tokenStartOffset = null; + + return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text, tokenStartOffset); } return null; diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs new file mode 100644 index 0000000000..aaf636deca --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -0,0 +1,142 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class SortParser : QueryExpressionParser, ISortParser +{ + /// + public SortExpression Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + Tokenize(source); + + SortExpression expression = ParseSort(resourceType); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual SortExpression ParseSort(ResourceType resourceType) + { + SortElementExpression firstElement = ParseSortElement(resourceType); + + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); + elementsBuilder.Add(firstElement); + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + SortElementExpression nextElement = ParseSortElement(resourceType); + elementsBuilder.Add(nextElement); + } + + return new SortExpression(elementsBuilder.ToImmutable()); + } + + protected virtual SortElementExpression ParseSortElement(ResourceType resourceType) + { + bool isAscending = true; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + isAscending = false; + } + + // An attribute or relationship name usually matches a single field, even when overridden in derived types. + // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [Attr] + // public short BonusPoints { get; set; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [Attr] + // public long BonusPoints { get; set; } + // } + // + // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends + // on which attribute is used. + // + // Because there is no syntax to pick one, ParseFieldChain() fails with an error. We could add optional upcast syntax + // (which would be required in this case) in the future to make it work, if desired. + + QueryExpression target; + + if (TokenStack.TryPeek(out nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + target = ParseFunction(resourceType); + } + else + { + string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; + target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + } + + return new SortElementExpression(target, isAscending); + } + + protected virtual bool IsFunction(string name) + { + ArgumentGuard.NotNullNorEmpty(name); + + return name == Keywords.Count; + } + + protected virtual FunctionExpression ParseFunction(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(resourceType); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Count function expected.", position); + } + + private CountExpression ParseCount(ResourceType resourceType) + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new QueryParseException($"Sorting on attribute '{attribute.PublicName}' is not allowed.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs similarity index 50% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index 0cabbcf76e..7bbea5c082 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -2,37 +2,30 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class SparseFieldSetParser : QueryExpressionParser +public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceType; - - public SparseFieldSetParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - + /// public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) { ArgumentGuard.NotNull(resourceType); - _resourceType = resourceType; - Tokenize(source); - SparseFieldSetExpression? expression = ParseSparseFieldSet(); + SparseFieldSetExpression? expression = ParseSparseFieldSet(resourceType); AssertTokenStackIsEmpty(); return expression; } - protected SparseFieldSetExpression? ParseSparseFieldSet() + protected virtual SparseFieldSetExpression? ParseSparseFieldSet(ResourceType resourceType) { ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); @@ -43,7 +36,9 @@ public SparseFieldSetParser(Action EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); + ResourceFieldChainExpression nextChain = + ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); + ResourceFieldAttribute nextField = nextChain.Fields.Single(); fieldSetBuilder.Add(nextField); } @@ -51,12 +46,12 @@ public SparseFieldSetParser(Action return fieldSetBuilder.Any() ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + protected override void ValidateField(ResourceFieldAttribute field, int position) { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - - _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); - - return ImmutableArray.Create(field); + if (field.IsViewBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Retrieving the {kind} '{field.PublicName}' is not allowed.", position); + } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs similarity index 63% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs rename to src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs index eceb05d211..e24ce9f90e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs @@ -1,12 +1,11 @@ -using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; +/// [PublicAPI] -public class SparseFieldTypeParser : QueryExpressionParser +public class SparseFieldTypeParser : QueryExpressionParser, ISparseFieldTypeParser { private readonly IResourceGraph _resourceGraph; @@ -17,22 +16,25 @@ public SparseFieldTypeParser(IResourceGraph resourceGraph) _resourceGraph = resourceGraph; } + /// public ResourceType Parse(string source) { Tokenize(source); - ResourceType resourceType = ParseSparseFieldTarget(); + ResourceType resourceType = ParseSparseFieldType(); AssertTokenStackIsEmpty(); return resourceType; } - private ResourceType ParseSparseFieldTarget() + protected virtual ResourceType ParseSparseFieldType() { + int position = GetNextTokenPositionOrEnd(); + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { - throw new QueryParseException("Parameter name expected."); + throw new QueryParseException("Parameter name expected.", position); } EatSingleCharacterToken(TokenKind.OpenBracket); @@ -46,28 +48,25 @@ private ResourceType ParseSparseFieldTarget() private ResourceType ParseResourceType() { + int position = GetNextTokenPositionOrEnd(); + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - return GetResourceType(token.Value!); + return GetResourceType(token.Value!, token.Position); } - throw new QueryParseException("Resource type expected."); + throw new QueryParseException("Resource type expected.", position); } - private ResourceType GetResourceType(string publicName) + private ResourceType GetResourceType(string publicName, int position) { ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); if (resourceType == null) { - throw new QueryParseException($"Resource type '{publicName}' does not exist."); + throw new QueryParseException($"Resource type '{publicName}' does not exist.", position); } return resourceType; } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs new file mode 100644 index 0000000000..4700127e1d --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +[PublicAPI] +public class Token +{ + public TokenKind Kind { get; } + public string? Value { get; } + public int Position { get; } + + public Token(TokenKind kind, int position) + { + Kind = kind; + Position = position; + } + + public Token(TokenKind kind, string value, int position) + : this(kind, position) + { + Value = value; + } + + public override string ToString() + { + return Value == null ? $"{Kind} at {Position}" : $"{Kind}: '{Value}' at {Position}"; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs similarity index 75% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs rename to src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs index f73cbd3418..23fd428bf5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; public enum TokenKind { diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs similarity index 99% rename from src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs rename to src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index e22b4ba86b..fb4920d1a4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -6,7 +6,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs new file mode 100644 index 0000000000..0182552b8e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IIncludeClauseBuilder +{ + Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs new file mode 100644 index 0000000000..fdbd55d095 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IOrderClauseBuilder +{ + Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs new file mode 100644 index 0000000000..7bf7b6b2f7 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs @@ -0,0 +1,17 @@ +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Drives conversion from into system trees. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IQueryableBuilder +{ + Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs new file mode 100644 index 0000000000..25e79c4202 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISelectClauseBuilder +{ + Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..4016532a09 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into and +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISkipTakeClauseBuilder +{ + Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs new file mode 100644 index 0000000000..f9e47cc714 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IWhereClauseBuilder +{ + Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs similarity index 51% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 80b3280355..3b0793f774 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -1,56 +1,40 @@ using System.Linq.Expressions; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into calls. -/// +/// [PublicAPI] -public class IncludeClauseBuilder : QueryClauseBuilder +public class IncludeClauseBuilder : QueryClauseBuilder, IIncludeClauseBuilder { private static readonly IncludeChainConverter IncludeChainConverter = new(); - private readonly Expression _source; - private readonly ResourceType _resourceType; - - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(resourceType); - - _source = source; - _resourceType = resourceType; - } - - public Expression ApplyInclude(IncludeExpression include) + public virtual Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(include); - return Visit(include, null); + return Visit(include, context); } - public override Expression VisitInclude(IncludeExpression expression, object? argument) + public override Expression VisitInclude(IncludeExpression expression, QueryClauseBuilderContext context) { // De-duplicate chains coming from derived relationships. HashSet propertyPaths = new(); - ApplyEagerLoads(_resourceType.EagerLoads, null, propertyPaths); + ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { ProcessRelationshipChain(chain, propertyPaths); } - return ToExpression(propertyPaths); + return ToExpression(context.Source, context.LambdaScope.Parameter.Type, propertyPaths); } - private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) + private static void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) { string? path = null; @@ -64,7 +48,7 @@ private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet eagerLoads, string? pathPrefix, ISet outputPropertyPaths) + private static void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) { foreach (EagerLoadAttribute eagerLoad in eagerLoads) { @@ -75,22 +59,22 @@ private void ApplyEagerLoads(IEnumerable eagerLoads, string? } } - private Expression ToExpression(HashSet propertyPaths) + private static Expression ToExpression(Expression source, Type entityType, HashSet propertyPaths) { - Expression source = _source; + Expression expression = source; foreach (string propertyPath in propertyPaths) { - source = IncludeExtensionMethodCall(source, propertyPath); + expression = IncludeExtensionMethodCall(expression, entityType, propertyPath); } - return source; + return expression; } - private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) + private static Expression IncludeExtensionMethodCall(Expression source, Type entityType, string navigationPropertyPath) { Expression navigationExpression = Expression.Constant(navigationPropertyPath); - return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", LambdaScope.Parameter.Type.AsArray(), source, navigationExpression); + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", entityType.AsArray(), source, navigationExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs new file mode 100644 index 0000000000..3f60ef8e55 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs @@ -0,0 +1,54 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// A scoped lambda expression with a unique name. Disposing the instance releases the claimed name, so it can be reused. +/// +[PublicAPI] +public sealed class LambdaScope : IDisposable +{ + private readonly LambdaScopeFactory _owner; + + /// + /// Gets the lambda parameter. For example, 'person' in: person => person.Account.Name == "Joe". + /// + public ParameterExpression Parameter { get; } + + /// + /// Gets the lambda accessor. For example, 'person.Account' in: person => person.Account.Name == "Joe". + /// + public Expression Accessor { get; } + + private LambdaScope(LambdaScopeFactory owner, ParameterExpression parameter, Expression accessor) + { + _owner = owner; + Parameter = parameter; + Accessor = accessor; + } + + internal static LambdaScope Create(LambdaScopeFactory owner, Type elementType, string parameterName, Expression? accessorExpression = null) + { + ArgumentGuard.NotNull(owner); + ArgumentGuard.NotNull(elementType); + ArgumentGuard.NotNullNorEmpty(parameterName); + + ParameterExpression parameter = Expression.Parameter(elementType, parameterName); + Expression accessor = accessorExpression ?? parameter; + + return new LambdaScope(owner, parameter, accessor); + } + + public LambdaScope WithAccessor(Expression accessorExpression) + { + ArgumentGuard.NotNull(accessorExpression); + + return new LambdaScope(_owner, Parameter, accessorExpression); + } + + public void Dispose() + { + _owner.Release(this); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs new file mode 100644 index 0000000000..cf8a30e1db --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using Humanizer; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Produces lambda parameters with unique names. +/// +[PublicAPI] +public sealed class LambdaScopeFactory +{ + private readonly HashSet _namesInScope = new(); + + /// + /// Finds the next unique lambda parameter name. Dispose the returned scope to release the claimed name, so it can be reused. + /// + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) + { + ArgumentGuard.NotNull(elementType); + + string parameterName = elementType.Name.Camelize(); + parameterName = EnsureUniqueName(parameterName); + _namesInScope.Add(parameterName); + + return LambdaScope.Create(this, elementType, parameterName, accessorExpression); + } + + private string EnsureUniqueName(string name) + { + if (!_namesInScope.Contains(name)) + { + return name; + } + + int counter = 1; + string alternativeName; + + do + { + counter++; + alternativeName = name + counter; + } + while (_namesInScope.Contains(alternativeName)); + + return alternativeName; + } + + internal void Release(LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(lambdaScope); + + _namesInScope.Remove(lambdaScope.Parameter.Name!); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs new file mode 100644 index 0000000000..09f0c5326e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -0,0 +1,63 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class OrderClauseBuilder : QueryClauseBuilder, IOrderClauseBuilder +{ + public virtual Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context) + { + ArgumentGuard.NotNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitSort(SortExpression expression, QueryClauseBuilderContext context) + { + QueryClauseBuilderContext nextContext = context; + + foreach (SortElementExpression sortElement in expression.Elements) + { + Expression sortExpression = Visit(sortElement, nextContext); + nextContext = nextContext.WithSource(sortExpression); + } + + return nextContext.Source; + } + + public override Expression VisitSortElement(SortElementExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression.Target, context); + LambdaExpression lambda = Expression.Lambda(body, context.LambdaScope.Parameter); + string operationName = GetOperationName(expression.IsAscending, context); + + return ExtensionMethodCall(context.Source, operationName, body.Type, lambda, context); + } + + private static string GetOperationName(bool isAscending, QueryClauseBuilderContext context) + { + bool hasPrecedingSort = false; + + if (context.Source is MethodCallExpression methodCall) + { + hasPrecedingSort = methodCall.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending"; + } + + if (hasPrecedingSort) + { + return isAscending ? "ThenBy" : "ThenByDescending"; + } + + return isAscending ? "OrderBy" : "OrderByDescending"; + } + + private static Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector, + QueryClauseBuilderContext context) + { + Type[] typeArguments = ArrayFactory.Create(context.LambdaScope.Parameter.Type, keyType); + return Expression.Call(context.ExtensionType, operationName, typeArguments, source, keySelector); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs similarity index 71% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs index fdbb3bc0c3..4e2743fa02 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs @@ -3,25 +3,21 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; /// /// Base class for transforming trees into system trees. /// -public abstract class QueryClauseBuilder : QueryExpressionVisitor +public abstract class QueryClauseBuilder : QueryExpressionVisitor { - protected LambdaScope LambdaScope { get; private set; } - - protected QueryClauseBuilder(LambdaScope lambdaScope) + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext argument) { - ArgumentGuard.NotNull(lambdaScope); - - LambdaScope = lambdaScope; + throw new NotSupportedException($"Unknown expression of type '{expression.GetType()}'."); } - public override Expression VisitCount(CountExpression expression, TArgument argument) + public override Expression VisitCount(CountExpression expression, QueryClauseBuilderContext context) { - Expression collectionExpression = Visit(expression.TargetCollection, argument); + Expression collectionExpression = Visit(expression.TargetCollection, context); Expression? propertyExpression = GetCollectionCount(collectionExpression); @@ -59,13 +55,13 @@ public override Expression VisitCount(CountExpression expression, TArgument argu return null; } - public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, QueryClauseBuilderContext context) { MemberExpression? property = null; foreach (ResourceFieldAttribute field in expression.Fields) { - Expression parentAccessor = property ?? LambdaScope.Accessor; + Expression parentAccessor = property ?? context.LambdaScope.Accessor; Type propertyType = field.Property.DeclaringType!; string propertyName = field.Property.Name; @@ -84,24 +80,4 @@ public override Expression VisitResourceFieldChain(ResourceFieldChainExpression return property!; } - - protected TResult WithLambdaScopeAccessor(Expression accessorExpression, Func action) - { - ArgumentGuard.NotNull(accessorExpression); - ArgumentGuard.NotNull(action); - - LambdaScope backupScope = LambdaScope; - - try - { - using (LambdaScope = LambdaScope.WithAccessor(accessorExpression)) - { - return action(); - } - } - finally - { - LambdaScope = backupScope; - } - } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs new file mode 100644 index 0000000000..42dcf80428 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -0,0 +1,88 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for *ClauseBuilder types. +/// +[PublicAPI] +public sealed class QueryClauseBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The resource type for . + /// + public ResourceType ResourceType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// The lambda expression currently in scope. + /// + public LambdaScope LambdaScope { get; } + + /// + /// The outer driver for building query clauses. + /// + public IQueryableBuilder QueryableBuilder { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IModel entityModel, + LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(entityModel); + ArgumentGuard.NotNull(lambdaScopeFactory); + ArgumentGuard.NotNull(lambdaScope); + ArgumentGuard.NotNull(queryableBuilder); + + Source = source; + ResourceType = resourceType; + LambdaScope = lambdaScope; + EntityModel = entityModel; + ExtensionType = extensionType; + LambdaScopeFactory = lambdaScopeFactory; + QueryableBuilder = queryableBuilder; + State = state; + } + + public QueryClauseBuilderContext WithSource(Expression source) + { + ArgumentGuard.NotNull(source); + + return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); + } + + public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(lambdaScope); + + return new QueryClauseBuilderContext(Source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, QueryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs new file mode 100644 index 0000000000..bec8329c34 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -0,0 +1,108 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class QueryableBuilder : IQueryableBuilder +{ + private readonly IIncludeClauseBuilder _includeClauseBuilder; + private readonly IWhereClauseBuilder _whereClauseBuilder; + private readonly IOrderClauseBuilder _orderClauseBuilder; + private readonly ISkipTakeClauseBuilder _skipTakeClauseBuilder; + private readonly ISelectClauseBuilder _selectClauseBuilder; + + public QueryableBuilder(IIncludeClauseBuilder includeClauseBuilder, IWhereClauseBuilder whereClauseBuilder, IOrderClauseBuilder orderClauseBuilder, + ISkipTakeClauseBuilder skipTakeClauseBuilder, ISelectClauseBuilder selectClauseBuilder) + { + ArgumentGuard.NotNull(includeClauseBuilder); + ArgumentGuard.NotNull(whereClauseBuilder); + ArgumentGuard.NotNull(orderClauseBuilder); + ArgumentGuard.NotNull(skipTakeClauseBuilder); + ArgumentGuard.NotNull(selectClauseBuilder); + + _includeClauseBuilder = includeClauseBuilder; + _whereClauseBuilder = whereClauseBuilder; + _orderClauseBuilder = orderClauseBuilder; + _skipTakeClauseBuilder = skipTakeClauseBuilder; + _selectClauseBuilder = selectClauseBuilder; + } + + public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) + { + ArgumentGuard.NotNull(layer); + ArgumentGuard.NotNull(context); + + Expression expression = context.Source; + + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceType, context); + } + + if (layer.Filter != null) + { + expression = ApplyFilter(expression, layer.Filter, layer.ResourceType, context); + } + + if (layer.Sort != null) + { + expression = ApplySort(expression, layer.Sort, layer.ResourceType, context); + } + + if (layer.Pagination != null) + { + expression = ApplyPagination(expression, layer.Pagination, layer.ResourceType, context); + } + + if (layer.Selection is { IsEmpty: false }) + { + expression = ApplySelection(expression, layer.Selection, layer.ResourceType, context); + } + + return expression; + } + + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _includeClauseBuilder.ApplyInclude(include, clauseContext); + } + + protected virtual Expression ApplyFilter(Expression source, FilterExpression filter, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _whereClauseBuilder.ApplyWhere(filter, clauseContext); + } + + protected virtual Expression ApplySort(Expression source, SortExpression sort, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _orderClauseBuilder.ApplyOrderBy(sort, clauseContext); + } + + protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _skipTakeClauseBuilder.ApplySkipTake(pagination, clauseContext); + } + + protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType, QueryableBuilderContext context) + { + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _selectClauseBuilder.ApplySelect(selection, clauseContext); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs new file mode 100644 index 0000000000..4659cca875 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for . +/// +[PublicAPI] +public sealed class QueryableBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The element type for . + /// + public Type ElementType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryableBuilderContext(Expression source, Type elementType, Type extensionType, IModel entityModel, LambdaScopeFactory lambdaScopeFactory, + object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(elementType); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(entityModel); + ArgumentGuard.NotNull(lambdaScopeFactory); + + Source = source; + ElementType = elementType; + ExtensionType = extensionType; + EntityModel = entityModel; + LambdaScopeFactory = lambdaScopeFactory; + State = state; + } + + public static QueryableBuilderContext CreateRoot(IQueryable source, Type extensionType, IModel model, object? state) + { + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(extensionType); + ArgumentGuard.NotNull(model); + + var lambdaScopeFactory = new LambdaScopeFactory(); + + return new QueryableBuilderContext(source.Expression, source.ElementType, extensionType, model, lambdaScopeFactory, state); + } + + public QueryClauseBuilderContext CreateClauseContext(IQueryableBuilder queryableBuilder, Expression source, ResourceType resourceType, + LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(queryableBuilder); + ArgumentGuard.NotNull(source); + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(lambdaScope); + + return new QueryClauseBuilderContext(source, resourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, queryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs similarity index 74% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index d206bd8b17..ce491304ef 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -2,68 +2,50 @@ using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into -/// calls. -/// +/// [PublicAPI] -public class SelectClauseBuilder : QueryClauseBuilder +public class SelectClauseBuilder : QueryClauseBuilder, ISelectClauseBuilder { private static readonly MethodInfo TypeGetTypeMethod = typeof(object).GetMethod("GetType")!; private static readonly MethodInfo TypeOpEqualityMethod = typeof(Type).GetMethod("op_Equality")!; private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); - private readonly Expression _source; - private readonly IModel _entityModel; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; private readonly IResourceFactory _resourceFactory; - public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory) - : base(lambdaScope) + public SelectClauseBuilder(IResourceFactory resourceFactory) { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(entityModel); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); ArgumentGuard.NotNull(resourceFactory); - _source = source; - _entityModel = entityModel; - _extensionType = extensionType; - _nameFactory = nameFactory; _resourceFactory = resourceFactory; } - public Expression ApplySelect(FieldSelection selection, ResourceType resourceType) + public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(selection); - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, LambdaScope, false); + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, context.LambdaScope, false, context); - LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); + LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); - return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); + return SelectExtensionMethodCall(context.ExtensionType, context.Source, context.LambdaScope.Parameter.Type, lambda); } private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, - bool lambdaAccessorRequiresTestForNull) + bool lambdaAccessorRequiresTestForNull, QueryClauseBuilderContext context) { - IEntityType entityType = _entityModel.FindEntityType(resourceType.ClrType)!; + IEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!; IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); Expression bodyInitializer = concreteEntityTypes.Length > 1 - ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope) - : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope); + ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope, context) + : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope, context); if (!lambdaAccessorRequiresTestForNull) { @@ -74,7 +56,7 @@ private Expression CreateLambdaBodyInitializer(FieldSelection selection, Resourc } private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, - IEnumerable concreteEntityTypes, LambdaScope lambdaScope) + IEnumerable concreteEntityTypes, LambdaScope lambdaScope, QueryClauseBuilderContext context) { IReadOnlySet resourceTypes = selection.GetResourceTypes(); Expression rootCondition = lambdaScope.Accessor; @@ -89,9 +71,10 @@ private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection se if (!fieldSelectors.IsEmpty) { - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType); + ICollection propertySelectors = + ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType, context.EntityModel); - MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)) + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) .Cast().ToArray(); NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType); @@ -118,19 +101,21 @@ private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); } - private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope) + private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, + QueryClauseBuilderContext context) { FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type); + ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type, context.EntityModel); - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) + .Cast().ToArray(); NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); return Expression.MemberInit(createInstance, propertyAssignments); } - private ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType) + private static ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType, + IModel entityModel) { var propertySelectors = new Dictionary(); @@ -139,7 +124,7 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. // And only selecting relationships implicitly means to fetch all scalar properties as well. - IncludeAllScalarProperties(elementType, propertySelectors); + IncludeAllScalarProperties(elementType, propertySelectors, entityModel); } IncludeFields(fieldSelectors, propertySelectors); @@ -148,9 +133,9 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe return propertySelectors.Values; } - private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) + private static void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors, IModel entityModel) { - IEntityType entityType = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEntityType entityType = entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) { @@ -194,7 +179,7 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary +[PublicAPI] +public class SkipTakeClauseBuilder : QueryClauseBuilder, ISkipTakeClauseBuilder +{ + public virtual Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context) + { + ArgumentGuard.NotNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitPagination(PaginationExpression expression, QueryClauseBuilderContext context) + { + Expression skipTakeExpression = context.Source; + + if (expression.PageSize != null) + { + int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; + + if (skipValue > 0) + { + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue, context); + } + + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value, context); + } + + return skipTakeExpression; + } + + private static Expression ExtensionMethodCall(Expression source, string operationName, int value, QueryClauseBuilderContext context) + { + Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); + + return Expression.Call(context.ExtensionType, operationName, context.LambdaScope.Parameter.Type.AsArray(), source, constant); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs similarity index 68% rename from src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index c2e8407c8e..981b2da1a3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -1,61 +1,44 @@ using System.Collections; using System.Linq.Expressions; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// -/// Transforms into -/// calls. -/// +/// [PublicAPI] -public class WhereClauseBuilder : QueryClauseBuilder +public class WhereClauseBuilder : QueryClauseBuilder, IWhereClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); - private readonly Expression _source; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source); - ArgumentGuard.NotNull(extensionType); - ArgumentGuard.NotNull(nameFactory); - - _source = source; - _extensionType = extensionType; - _nameFactory = nameFactory; - } - - public Expression ApplyWhere(FilterExpression filter) + public virtual Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context) { ArgumentGuard.NotNull(filter); - LambdaExpression lambda = GetPredicateLambda(filter); + LambdaExpression lambda = GetPredicateLambda(filter, context); - return WhereExtensionMethodCall(lambda); + return WhereExtensionMethodCall(lambda, context); } - private LambdaExpression GetPredicateLambda(FilterExpression filter) + private LambdaExpression GetPredicateLambda(FilterExpression filter, QueryClauseBuilderContext context) { - Expression body = Visit(filter, null); - return Expression.Lambda(body, LambdaScope.Parameter); + Expression body = Visit(filter, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); } - private Expression WhereExtensionMethodCall(LambdaExpression predicate) + private static Expression WhereExtensionMethodCall(LambdaExpression predicate, QueryClauseBuilderContext context) { - return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); + return Expression.Call(context.ExtensionType, "Where", context.LambdaScope.Parameter.Type.AsArray(), context.Source, predicate); } - public override Expression VisitHas(HasExpression expression, object? argument) + public override Expression VisitHas(HasExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetCollection, argument); + Expression property = Visit(expression.TargetCollection, context); Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); @@ -68,11 +51,14 @@ public override Expression VisitHas(HasExpression expression, object? argument) if (expression.Filter != null) { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); + ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType; + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); - var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); - predicate = builder.GetPredicateLambda(expression.Filter); + var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + lambdaScope, context.QueryableBuilder, context.State); + + predicate = GetPredicateLambda(expression.Filter, nestedContext); } return AnyExtensionMethodCall(elementType, property, predicate); @@ -85,9 +71,9 @@ private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Exp : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitIsType(IsTypeExpression expression, object? argument) + public override Expression VisitIsType(IsTypeExpression expression, QueryClauseBuilderContext context) { - Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor; + Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, context) : context.LambdaScope.Accessor; TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); if (expression.Child == null) @@ -96,21 +82,23 @@ public override Expression VisitIsType(IsTypeExpression expression, object? argu } UnaryExpression derivedAccessor = Expression.Convert(property, expression.DerivedType.ClrType); - Expression filter = WithLambdaScopeAccessor(derivedAccessor, () => Visit(expression.Child, argument)); + + QueryClauseBuilderContext derivedContext = context.WithLambdaScope(context.LambdaScope.WithAccessor(derivedAccessor)); + Expression filter = Visit(expression.Child, derivedContext); return Expression.AndAlso(typeCheck, filter); } - public override Expression VisitMatchText(MatchTextExpression expression, object? argument) + public override Expression VisitMatchText(MatchTextExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, argument); + Expression property = Visit(expression.TargetAttribute, context); if (property.Type != typeof(string)) { throw new InvalidOperationException("Expression must be a string."); } - Expression text = Visit(expression.TextValue, property.Type); + Expression text = Visit(expression.TextValue, context); if (expression.MatchKind == TextMatchKind.StartsWith) { @@ -125,9 +113,9 @@ public override Expression VisitMatchText(MatchTextExpression expression, object return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, object? argument) + public override Expression VisitAny(AnyExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, argument); + Expression property = Visit(expression.TargetAttribute, context); var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; @@ -145,9 +133,9 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, object? argument) + public override Expression VisitLogical(LogicalExpression expression, QueryClauseBuilderContext context) { - var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); + var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, context))); if (expression.Operator == LogicalOperator.And) { @@ -178,18 +166,18 @@ private static BinaryExpression Compose(Queue argumentQueue, Func).MakeGenericType(leftType); } - Type? rightType = TryResolveFixedType(right); + Type? rightType = TryResolveFixedType(right, context); if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) { @@ -226,13 +214,13 @@ private Type ResolveCommonType(QueryExpression left, QueryExpression right) return leftType; } - private Type ResolveFixedType(QueryExpression expression) + private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) { - Expression result = Visit(expression, null); + Expression result = Visit(expression, context); return result.Type; } - private Type? TryResolveFixedType(QueryExpression expression) + private Type? TryResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) { if (expression is CountExpression) { @@ -241,18 +229,18 @@ private Type ResolveFixedType(QueryExpression expression) if (expression is ResourceFieldChainExpression chain) { - Expression child = Visit(chain, null); + Expression child = Visit(chain, context); return child.Type; } return null; } - private static Expression WrapInConvert(Expression expression, Type? targetType) + private static Expression WrapInConvert(Expression expression, Type targetType) { try { - return targetType != null && expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; + return expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; } catch (InvalidOperationException exception) { @@ -260,12 +248,12 @@ private static Expression WrapInConvert(Expression expression, Type? targetType) } } - public override Expression VisitNullConstant(NullConstantExpression expression, object? argument) + public override Expression VisitNullConstant(NullConstantExpression expression, QueryClauseBuilderContext context) { return NullConstant; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, object? argument) + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, QueryClauseBuilderContext context) { Type type = expression.TypedValue.GetType(); return expression.TypedValue.CreateTupleAccessExpressionForConstant(type); diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs similarity index 99% rename from src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs rename to src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs index ab1edf9f9e..57df16c62b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// public sealed class SparseFieldSetCache : ISparseFieldSetCache diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs similarity index 96% rename from src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs rename to src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs index 9c77f53938..ef81aece33 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/SystemExpressionExtensions.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using System.Reflection; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; internal static class SystemExpressionExtensions { diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs new file mode 100644 index 0000000000..52e31c8dc7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[PublicAPI] +public static class BuiltInPatterns +{ + public static FieldChainPattern SingleField { get; } = FieldChainPattern.Parse("F"); + public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); + public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A"); + public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); + public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); + public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M"); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs new file mode 100644 index 0000000000..8156e1474e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a dot-separated resource field chain is invalid. +/// +internal sealed class FieldChainFormatException : FormatException +{ + /// + /// Gets the zero-based error position in the field chain, or at its end. + /// + public int Position { get; } + + public FieldChainFormatException(int position, string message) + : base(message) + { + Position = position; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs new file mode 100644 index 0000000000..e6dac0b258 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs @@ -0,0 +1,34 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a dot-separated resource field chain from text into a list of field names. +/// +internal sealed class FieldChainParser +{ + public IEnumerable Parse(string source) + { + ArgumentGuard.NotNull(source); + + if (source != string.Empty) + { + var fields = new List(source.Split('.')); + int position = 0; + + foreach (string field in fields) + { + string trimmed = field.Trim(); + + if (field.Length == 0 || trimmed.Length != field.Length) + { + throw new FieldChainFormatException(position, "Field name expected."); + } + + position += field.Length + 1; + } + + return fields; + } + + return Array.Empty(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs new file mode 100644 index 0000000000..4928984030 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -0,0 +1,168 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// A pattern that can be matched against a dot-separated resource field chain. +/// +[PublicAPI] +public sealed class FieldChainPattern +{ + /// + /// Gets the set of possible resource field types. + /// + internal FieldTypes Choices { get; } + + /// + /// Indicates whether this pattern segment must match at least one resource field. + /// + internal bool AtLeastOne { get; } + + /// + /// Indicates whether this pattern can match multiple resource fields. + /// + internal bool AtMostOne { get; } + + /// + /// Gets the next pattern segment in the chain, or null if at the end. + /// + internal FieldChainPattern? Next { get; } + + internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, FieldChainPattern? next) + { + if (choices == FieldTypes.None) + { + throw new ArgumentException("The set of choices cannot be empty.", nameof(choices)); + } + + Choices = choices; + AtLeastOne = atLeastOne; + AtMostOne = atMostOne; + Next = next; + } + + /// + /// Creates a pattern from the specified text that can be matched against. + /// + /// + /// Patterns are similar to regular expressions, but a lot simpler. They consist of a sequence of terms. A term can be a single character or a character + /// choice, optionally followed by a quantifier. + ///

+ /// The following characters can be used: + /// + /// + /// M + /// + /// Matches a to-many relationship. + /// + /// O + /// + /// Matches a to-one relationship. + /// + /// R + /// + /// Matches a relationship. + /// + /// A + /// + /// Matches an attribute. + /// + /// F + /// + /// Matches a field. + /// + /// + /// + ///

+ ///

+ /// A character choice contains a set of characters, surrounded by brackets. One of the choices must match. For example, "[MO]" matches a relationship, + /// but not at attribute. + ///

+ /// A quantifier is used to indicate how many times its term directly to the left can occur. + /// + /// + /// ? + /// + /// Matches its term zero or one times. + /// + /// * + /// + /// Matches its term zero or more times. + /// + /// + + /// + /// Matches its term one or more times. + /// + /// + /// + /// + /// For example, the pattern "M?O*A" matches "children.parent.name", "parent.parent.name" and "name". + /// + ///
+ /// + /// The pattern is invalid. + /// + public static FieldChainPattern Parse(string pattern) + { + var parser = new PatternParser(); + return parser.Parse(pattern); + } + + /// + /// Matches the specified resource field chain against this pattern. + /// + /// + /// The dot-separated chain of resource field names. + /// + /// + /// The parent resource type to start matching from. + /// + /// + /// Match options, defaults to . + /// + /// + /// When provided, logs the matching steps at level. + /// + /// + /// The match result. + /// + public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, + ILoggerFactory? loggerFactory = null) + { + ArgumentGuard.NotNull(fieldChain); + ArgumentGuard.NotNull(resourceType); + + ILogger logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + var matcher = new PatternMatcher(this, options, logger); + return matcher.Match(fieldChain, resourceType); + } + + /// + /// Returns only the first segment of this pattern chain. Used for diagnostic messages. + /// + internal FieldChainPattern WithoutNext() + { + return Next == null ? this : new FieldChainPattern(Choices, AtLeastOne, AtMostOne, null); + } + + /// + /// Gets the text representation of this pattern. + /// + public override string ToString() + { + var formatter = new PatternTextFormatter(this); + return formatter.Format(); + } + + /// + /// Gets a human-readable description of this pattern. + /// + public string GetDescription() + { + var formatter = new PatternDescriptionFormatter(this); + return formatter.Format(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs new file mode 100644 index 0000000000..645f53ef50 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates how to perform matching a pattern against a resource field chain. +/// +[Flags] +public enum FieldChainPatternMatchOptions +{ + /// + /// Specifies that no options are set. + /// + None = 0, + + /// + /// Specifies to include fields on derived types in the search for a matching field. + /// + AllowDerivedTypes = 1 +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs new file mode 100644 index 0000000000..8c18c4448e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +internal static class FieldTypeExtensions +{ + public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool pluralize, bool prefix) + { + int startOffset = builder.Length; + + if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.ToOneRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Attribute) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Relationship) && !choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("field", pluralize, prefix, false, builder, startOffset); + } + } + + private static void WriteChoice(string typeText, bool pluralize, bool prefix, bool isAttribute, StringBuilder builder, int startOffset) + { + if (builder.Length > startOffset) + { + builder.Append(" or "); + } + + if (prefix && !pluralize) + { + builder.Append(isAttribute ? "an " : "a "); + } + + builder.Append(typeText); + + if (pluralize) + { + builder.Append('s'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs new file mode 100644 index 0000000000..8011ec3ec4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[Flags] +internal enum FieldTypes +{ + None = 0, + Attribute = 1, + ToOneRelationship = 1 << 1, + ToManyRelationship = 1 << 2, + Relationship = ToOneRelationship | ToManyRelationship, + Field = Attribute | Relationship +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs new file mode 100644 index 0000000000..8fe555b2f3 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -0,0 +1,158 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates a failure to match a pattern against a resource field chain. +/// +internal sealed class MatchError +{ + /// + /// Gets the match failure message. + /// + public string Message { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the failure occurred. + /// + public int Position { get; } + + /// + /// Indicates whether this error occurred due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private MatchError(string message, int position, bool isFieldChainError) + { + Message = message; + Position = position; + IsFieldChainError = isFieldChainError; + } + + public static MatchError CreateForBrokenFieldChain(FieldChainFormatException exception) + { + return new MatchError(exception.Message, exception.Position, true); + } + + public static MatchError CreateForUnknownField(int position, ResourceType? resourceType, string publicName, bool allowDerivedTypes) + { + bool hasDerivedTypes = allowDerivedTypes && resourceType != null && resourceType.DirectlyDerivedTypes.Any(); + + var builder = new MessageBuilder(); + + builder.WriteDoesNotExist(publicName); + builder.WriteResourceType(resourceType); + builder.WriteOrDerivedTypes(hasDerivedTypes); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, true); + } + + public static MatchError CreateForMultipleDerivedTypes(int position, ResourceType resourceType, string publicName) + { + string message = $"Field '{publicName}' is defined on multiple types that derive from resource type '{resourceType}'."; + return new MatchError(message, position, true); + } + + public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public static MatchError CreateForTooMuchInput(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteEndOfChain(); + + if (choices != FieldTypes.None) + { + builder.WriteOr(); + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + } + + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public override string ToString() + { + return Message; + } + + private sealed class MessageBuilder + { + private readonly StringBuilder _builder = new(); + + public void WriteDoesNotExist(string publicName) + { + _builder.Append($"Field '{publicName}' does not exist"); + } + + public void WriteOrDerivedTypes(bool hasDerivedTypes) + { + if (hasDerivedTypes) + { + _builder.Append(" or any of its derived types"); + } + } + + public void WriteEndOfChain() + { + _builder.Append("End of field chain"); + } + + public void WriteOr() + { + _builder.Append(" or "); + } + + public void WriteChoices(FieldTypes choices) + { + bool firstCharToUpper = _builder.Length == 0; + choices.WriteTo(_builder, false, false); + + if (firstCharToUpper && _builder.Length > 0) + { + _builder[0] = char.ToUpperInvariant(_builder[0]); + } + } + + public void WriteResourceType(ResourceType? resourceType) + { + if (resourceType != null) + { + _builder.Append($" on resource type '{resourceType}'"); + } + } + + public void WriteExpected() + { + _builder.Append(" expected"); + } + + public void WriteEnd() + { + _builder.Append('.'); + } + + public override string ToString() + { + return _builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs new file mode 100644 index 0000000000..90255191d7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -0,0 +1,282 @@ +using System.Collections.Immutable; +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Immutable intermediate state, used while matching a resource field chain against a pattern. +/// +internal sealed class MatchState +{ + /// + /// The successful parent match. Chaining together with those of parents produces the full match. + /// + private readonly MatchState? _parentMatch; + + /// + /// The remaining chain of pattern segments. The first segment is being matched against. + /// + public FieldChainPattern? Pattern { get; } + + /// + /// The resource type to find the next field on. + /// + public ResourceType? ResourceType { get; } + + /// + /// The fields matched against this pattern segment. + /// + public IImmutableList FieldsMatched { get; } + + /// + /// The remaining fields to be matched against the remaining pattern chain. + /// + public LinkedListNode? FieldsRemaining { get; } + + /// + /// The error in case matching this pattern segment failed. + /// + public MatchError? Error { get; } + + private MatchState(FieldChainPattern? pattern, ResourceType? resourceType, IImmutableList fieldsMatched, + LinkedListNode? fieldsRemaining, MatchError? error, MatchState? parentMatch) + { + Pattern = pattern; + ResourceType = resourceType; + FieldsMatched = fieldsMatched; + FieldsRemaining = fieldsRemaining; + Error = error; + _parentMatch = parentMatch; + } + + public static MatchState Create(FieldChainPattern pattern, string fieldChainText, ResourceType resourceType) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(fieldChainText); + ArgumentGuard.NotNull(resourceType); + + try + { + var parser = new FieldChainParser(); + IEnumerable fieldChain = parser.Parse(fieldChainText); + + LinkedListNode? remainingHead = new LinkedList(fieldChain).First; + return new MatchState(pattern, resourceType, ImmutableArray.Empty, remainingHead, null, null); + } + catch (FieldChainFormatException exception) + { + var error = MatchError.CreateForBrokenFieldChain(exception); + return new MatchState(pattern, resourceType, ImmutableArray.Empty, null, error, null); + } + } + + /// + /// Returns a new state for successfully matching the top-level remaining field. Moves one position forward in the resource field chain. + /// + public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue) + { + ArgumentGuard.NotNull(matchedValue); + AssertIsSuccess(this); + + IImmutableList fieldsMatched = FieldsMatched.Add(matchedValue); + LinkedListNode? fieldsRemaining = FieldsRemaining!.Next; + ResourceType? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; + + return new MatchState(Pattern, resourceType, fieldsMatched, fieldsRemaining, null, _parentMatch); + } + + /// + /// Returns a new state for matching the next pattern segment. + /// + public MatchState SuccessMoveToNextPattern() + { + AssertIsSuccess(this); + AssertHasPattern(); + + return new MatchState(Pattern!.Next, ResourceType, ImmutableArray.Empty, FieldsRemaining, null, this); + } + + /// + /// Returns a new state for match failure due to an unknown field. + /// + public MatchState FailureForUnknownField(string publicName, bool allowDerivedTypes) + { + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForUnknownField(position, ResourceType, publicName, allowDerivedTypes); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field exists on multiple derived types. + /// + public MatchState FailureForMultipleDerivedTypes(string publicName) + { + AssertHasResourceType(); + + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForMultipleDerivedTypes(position, ResourceType!, publicName); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field type is not one of the pattern choices. + /// + public MatchState FailureForFieldTypeMismatch(FieldTypes choices, FieldTypes chosenFieldType) + { + FieldTypes allChoices = IncludeChoicesFromParentMatch(choices); + int position = GetAbsolutePosition(chosenFieldType != FieldTypes.None); + var error = MatchError.CreateForFieldTypeMismatch(position, ResourceType, allChoices); + + return Failure(error); + } + + /// + /// Combines the choices of this pattern segment with choices from parent matches, if they can match more. + /// + private FieldTypes IncludeChoicesFromParentMatch(FieldTypes choices) + { + if (choices == FieldTypes.Field) + { + // We already match everything, there's no point in looking deeper. + return choices; + } + + if (_parentMatch is { Pattern: not null }) + { + // The choices from the parent pattern segment are available when: + // - The parent pattern can match multiple times. + // - The parent pattern is optional and matched nothing. + if (!_parentMatch.Pattern.AtMostOne || (!_parentMatch.Pattern.AtLeastOne && _parentMatch.FieldsMatched.Count == 0)) + { + FieldTypes mergedChoices = choices | _parentMatch.Pattern.Choices; + + // If the parent pattern didn't match anything, look deeper. + if (_parentMatch.FieldsMatched.Count == 0) + { + mergedChoices = _parentMatch.IncludeChoicesFromParentMatch(mergedChoices); + } + + return mergedChoices; + } + } + + return choices; + } + + /// + /// Returns a new state for match failure because the resource field chain contains more fields than expected. + /// + public MatchState FailureForTooMuchInput() + { + FieldTypes parentChoices = IncludeChoicesFromParentMatch(FieldTypes.None); + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.ResourceType, parentChoices); + + return Failure(error); + } + + private MatchState Failure(MatchError error) + { + return new MatchState(Pattern, ResourceType, FieldsMatched, FieldsRemaining, error, _parentMatch); + } + + private int GetAbsolutePosition(bool hasLeadingDot) + { + int length = 0; + MatchState? currentState = this; + + while (currentState != null) + { + length += currentState.FieldsMatched.Sum(field => field.PublicName.Length + 1); + currentState = currentState._parentMatch; + } + + length = length > 0 ? length - 1 : 0; + + if (length > 0 && hasLeadingDot) + { + length++; + } + + return length; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + if (FieldsMatched.Count == 0 && FieldsRemaining == null && Pattern == null) + { + builder.Append("EMPTY"); + } + else + { + builder.Append(Error == null ? "SUCCESS: " : "FAILED: "); + builder.Append("Matched '"); + builder.Append(string.Join('.', FieldsMatched)); + builder.Append("' against '"); + builder.Append(Pattern?.WithoutNext()); + builder.Append("' with remaining '"); + builder.Append(string.Join('.', FieldsRemaining.ToEnumerable())); + builder.Append('\''); + } + + if (_parentMatch != null) + { + builder.Append(" -> "); + builder.Append(_parentMatch); + } + + return builder.ToString(); + } + + public IReadOnlyList GetAllFieldsMatched() + { + Stack> matchStack = new(); + MatchState? current = this; + + while (current != null) + { + matchStack.Push(current.FieldsMatched); + current = current._parentMatch; + } + + List fields = new(); + + while (matchStack.Count > 0) + { + IImmutableList matches = matchStack.Pop(); + fields.AddRange(matches); + } + + return fields; + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + private void AssertHasResourceType() + { + if (ResourceType == null) + { + throw new InvalidOperationException("Internal error: Resource type is unavailable."); + } + } + + private void AssertHasPattern() + { + if (Pattern == null) + { + throw new InvalidOperationException("Internal error: Pattern chain is unavailable."); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs new file mode 100644 index 0000000000..63c6c67876 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; + +#pragma warning disable CA2254 // Template should be a static expression + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Logs the pattern matching steps at level. +/// +internal sealed class MatchTraceScope : IDisposable +{ + private readonly FieldChainPattern? _pattern; + private readonly bool _isEnabled; + private readonly ILogger _logger; + private readonly int _indentDepth; + private MatchState? _endState; + + private MatchTraceScope(FieldChainPattern? pattern, bool isEnabled, ILogger logger, int indentDepth) + { + _pattern = pattern; + _isEnabled = isEnabled; + _logger = logger; + _indentDepth = indentDepth; + } + + public static MatchTraceScope CreateRoot(MatchState startState, ILogger logger) + { + ArgumentGuard.NotNull(startState); + ArgumentGuard.NotNull(logger); + + bool isEnabled = logger.IsEnabled(LogLevel.Trace); + + if (isEnabled) + { + string fieldsRemaining = FormatFieldsRemaining(startState); + string message = $"Start matching pattern '{startState.Pattern}' against the complete chain '{fieldsRemaining}'"; + logger.LogTrace(message); + } + + return new MatchTraceScope(startState.Pattern, isEnabled, logger, 0); + } + + public MatchTraceScope CreateChild(MatchState startState) + { + ArgumentGuard.NotNull(startState); + + int indentDepth = _indentDepth + 1; + FieldChainPattern? patternSegment = startState.Pattern?.WithoutNext(); + + if (_isEnabled) + { + string fieldsRemaining = FormatFieldsRemaining(startState); + LogMessage($"Start matching '{patternSegment}' against the remaining chain '{fieldsRemaining}'"); + } + + return new MatchTraceScope(patternSegment, _isEnabled, _logger, indentDepth); + } + + public void LogMatchResult(MatchState resultState) + { + ArgumentGuard.NotNull(resultState); + + if (_isEnabled) + { + if (resultState.Error == null) + { + string fieldsMatched = FormatFieldsMatched(resultState); + LogMessage($"Match '{_pattern}' against '{fieldsMatched}': Success"); + } + else + { + List chain = new(resultState.FieldsMatched.Select(attribute => attribute.PublicName)); + + if (resultState.FieldsRemaining != null) + { + chain.Add(resultState.FieldsRemaining.Value); + } + + string chainText = string.Join('.', chain); + LogMessage($"Match '{_pattern}' against '{chainText}': Failed"); + } + } + } + + public void LogBacktrackTo(MatchState backtrackState) + { + ArgumentGuard.NotNull(backtrackState); + + if (_isEnabled) + { + string fieldsMatched = FormatFieldsMatched(backtrackState); + LogMessage($"Backtracking to successful match against '{fieldsMatched}'"); + } + } + + public void SetResult(MatchState endState) + { + ArgumentGuard.NotNull(endState); + + _endState = endState; + } + + public void Dispose() + { + if (_endState == null) + { + throw new InvalidOperationException("Internal error: End state must be set before leaving trace scope."); + } + + if (_isEnabled) + { + LogMessage(_endState.Error == null ? "Matching completed with success" : "Matching completed with failure"); + } + } + + private static string FormatFieldsRemaining(MatchState state) + { + return string.Join('.', state.FieldsRemaining.ToEnumerable()); + } + + private static string FormatFieldsMatched(MatchState state) + { + return string.Join('.', state.FieldsMatched); + } + + private void LogMessage(string message) + { + string indent = new(' ', _indentDepth * 2); + _logger.LogTrace($"{indent}{message}"); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs new file mode 100644 index 0000000000..841dbb2050 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into a human-readable description. +/// +internal sealed class PatternDescriptionFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternDescriptionFormatter(FieldChainPattern pattern) + { + ArgumentGuard.NotNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteSeparator(builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + WriteChoices(current, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteSeparator(StringBuilder builder) + { + if (builder.Length > 0) + { + builder.Append(", followed by "); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? "an optional " : "zero or more "); + } + else if (!atMostOne) + { + builder.Append("one or more "); + } + } + + private static void WriteChoices(FieldChainPattern pattern, StringBuilder builder) + { + bool pluralize = !pattern.AtMostOne; + bool prefix = pattern is { AtLeastOne: true, AtMostOne: true }; + + pattern.Choices.WriteTo(builder, pluralize, prefix); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs new file mode 100644 index 0000000000..0a1fa13d2c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a is invalid. +/// +[PublicAPI] +public sealed class PatternFormatException : FormatException +{ + /// + /// Gets the text of the invalid pattern. + /// + public string Pattern { get; } + + /// + /// Gets the zero-based error position in , or at its end. + /// + public int Position { get; } + + public PatternFormatException(string pattern, int position, string message) + : base(message) + { + Pattern = pattern; + Position = position; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs new file mode 100644 index 0000000000..f32ff953f4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs @@ -0,0 +1,63 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Represents the result of matching a dot-separated resource field chain against a pattern. +/// +[PublicAPI] +public sealed class PatternMatchResult +{ + /// + /// Indicates whether the match succeeded. + /// + public bool IsSuccess { get; } + + /// + /// The resolved field chain, when is true. + /// + /// + /// The chain may be empty, if the pattern allows for that. + /// + public IReadOnlyList FieldChain { get; } + + /// + /// Gets the match failure message, when is false. + /// + public string FailureMessage { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the match failure occurred. + /// + public int FailurePosition { get; } + + /// + /// Indicates whether the match failed due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private PatternMatchResult(bool isSuccess, IReadOnlyList fieldChain, string failureMessage, int failurePosition, + bool isFieldChainError) + { + IsSuccess = isSuccess; + FieldChain = fieldChain; + FailureMessage = failureMessage; + FailurePosition = failurePosition; + IsFieldChainError = isFieldChainError; + } + + internal static PatternMatchResult CreateForSuccess(IReadOnlyList fieldChain) + { + ArgumentGuard.NotNull(fieldChain); + + return new PatternMatchResult(true, fieldChain, string.Empty, -1, false); + } + + internal static PatternMatchResult CreateForFailure(MatchError error) + { + ArgumentGuard.NotNull(error); + + return new PatternMatchResult(false, Array.Empty(), error.Message, error.Position, error.IsFieldChainError); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs new file mode 100644 index 0000000000..8172bdaa95 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -0,0 +1,241 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Matches a resource field chain against a pattern. +/// +internal sealed class PatternMatcher +{ + private readonly FieldChainPattern _pattern; + private readonly ILogger _logger; + private readonly bool _allowDerivedTypes; + + public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ILogger logger) + { + ArgumentGuard.NotNull(pattern); + ArgumentGuard.NotNull(logger); + + _pattern = pattern; + _logger = logger; + _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); + } + + public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + { + ArgumentGuard.NotNull(fieldChain); + ArgumentGuard.NotNull(resourceType); + + var startState = MatchState.Create(_pattern, fieldChain, resourceType); + + if (startState.Error != null) + { + return PatternMatchResult.CreateForFailure(startState.Error); + } + + using var traceScope = MatchTraceScope.CreateRoot(startState, _logger); + + MatchState endState = MatchPattern(startState, traceScope); + traceScope.SetResult(endState); + + return endState.Error == null + ? PatternMatchResult.CreateForSuccess(endState.GetAllFieldsMatched()) + : PatternMatchResult.CreateForFailure(endState.Error); + } + + /// + /// Matches the first segment in against . + /// + private MatchState MatchPattern(MatchState state, MatchTraceScope parentTraceScope) + { + AssertIsSuccess(state); + + FieldChainPattern? patternSegment = state.Pattern; + using MatchTraceScope traceScope = parentTraceScope.CreateChild(state); + + if (patternSegment == null) + { + MatchState endState = state.FieldsRemaining == null ? state : state.FailureForTooMuchInput(); + traceScope.LogMatchResult(endState); + traceScope.SetResult(endState); + + return endState; + } + + // Build a stack of successful matches against this pattern segment, incrementally trying to match more fields. + Stack backtrackStack = new(); + + if (!patternSegment.AtLeastOne) + { + // Also include match against empty chain, which always succeeds. + traceScope.LogMatchResult(state); + backtrackStack.Push(state); + } + + MatchState greedyState = state; + + do + { + if (!patternSegment.AtLeastOne && greedyState.FieldsRemaining == null) + { + // Already added above. + continue; + } + + greedyState = MatchField(greedyState); + traceScope.LogMatchResult(greedyState); + + if (greedyState.Error == null) + { + backtrackStack.Push(greedyState); + } + } + while (!patternSegment.AtMostOne && greedyState is { FieldsRemaining: not null, Error: null }); + + // The best error to return is the failure from matching the remaining pattern chain at the most-greedy successful match. + // If matching against the remaining pattern chains doesn't fail, use the most-greedy failure itself. + MatchState bestErrorEndState = greedyState; + + // Evaluate the stacked matches (greedy, so longest first) against the remaining pattern chain. + while (backtrackStack.Count > 0) + { + MatchState backtrackState = backtrackStack.Pop(); + + if (backtrackState != greedyState) + { + // If we're at to most-recent match, and it succeeded, then we're not really backtracking. + traceScope.LogBacktrackTo(backtrackState); + } + + // Match the remaining pattern chain against the remaining field chain. + MatchState endState = MatchPattern(backtrackState.SuccessMoveToNextPattern(), traceScope); + + if (endState.Error == null) + { + traceScope.SetResult(endState); + return endState; + } + + if (bestErrorEndState == greedyState) + { + bestErrorEndState = endState; + } + } + + if (greedyState.Error?.IsFieldChainError == true) + { + // There was an error in the field chain itself, irrespective of backtracking. + // It is therefore more relevant to report over any other error. + bestErrorEndState = greedyState; + } + + traceScope.SetResult(bestErrorEndState); + return bestErrorEndState; + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + /// + /// Matches the first remaining field against the set of choices in the current pattern segment. + /// + private MatchState MatchField(MatchState state) + { + FieldTypes choices = state.Pattern!.Choices; + ResourceFieldAttribute? chosenField = null; + + if (state.FieldsRemaining != null) + { + string publicName = state.FieldsRemaining.Value; + + HashSet fields = LookupFields(state.ResourceType, publicName); + + if (!fields.Any()) + { + return state.FailureForUnknownField(publicName, _allowDerivedTypes); + } + + chosenField = fields.First(); + + fields.RemoveWhere(field => !IsTypeMatch(field, choices)); + + if (fields.Count == 1) + { + return state.SuccessMoveForwardOneField(fields.First()); + } + + if (fields.Count > 1) + { + return state.FailureForMultipleDerivedTypes(publicName); + } + } + + FieldTypes chosenFieldType = GetFieldType(chosenField); + return state.FailureForFieldTypeMismatch(choices, chosenFieldType); + } + + /// + /// Lookup the specified field in the resource graph. + /// + private HashSet LookupFields(ResourceType? resourceType, string publicName) + { + HashSet fields = new(); + + if (resourceType != null) + { + if (_allowDerivedTypes) + { + IReadOnlySet attributes = resourceType.GetAttributesInTypeOrDerived(publicName); + fields.UnionWith(attributes); + + IReadOnlySet relationships = resourceType.GetRelationshipsInTypeOrDerived(publicName); + fields.UnionWith(relationships); + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + + if (relationship != null) + { + fields.Add(relationship); + } + } + } + + return fields; + } + + private static bool IsTypeMatch(ResourceFieldAttribute field, FieldTypes types) + { + FieldTypes chosenType = GetFieldType(field); + + return (types & chosenType) != FieldTypes.None; + } + + private static FieldTypes GetFieldType(ResourceFieldAttribute? field) + { + return field switch + { + HasManyAttribute => FieldTypes.ToManyRelationship, + HasOneAttribute => FieldTypes.ToOneRelationship, + RelationshipAttribute => FieldTypes.Relationship, + AttrAttribute => FieldTypes.Attribute, + null => FieldTypes.None, + _ => FieldTypes.Field + }; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs new file mode 100644 index 0000000000..a00ec26846 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -0,0 +1,186 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a field chain pattern from text into a chain of segments. +/// +internal sealed class PatternParser +{ + private static readonly Dictionary CharToTokenTable = new() + { + ['?'] = Token.QuestionMark, + ['+'] = Token.Plus, + ['*'] = Token.Asterisk, + ['['] = Token.BracketOpen, + [']'] = Token.BracketClose, + ['M'] = Token.ToManyRelationship, + ['O'] = Token.ToOneRelationship, + ['R'] = Token.Relationship, + ['A'] = Token.Attribute, + ['F'] = Token.Field + }; + + private static readonly Dictionary TokenToFieldTypeTable = new() + { + [Token.ToManyRelationship] = FieldTypes.ToManyRelationship, + [Token.ToOneRelationship] = FieldTypes.ToOneRelationship, + [Token.Relationship] = FieldTypes.Relationship, + [Token.Attribute] = FieldTypes.Attribute, + [Token.Field] = FieldTypes.Field + }; + + private static readonly HashSet QuantifierTokens = new(new[] + { + Token.QuestionMark, + Token.Plus, + Token.Asterisk + }); + + private string _source = null!; + private Queue _tokenQueue = null!; + private int _position; + + public FieldChainPattern Parse(string source) + { + ArgumentGuard.NotNull(source); + + _source = source; + EnqueueTokens(); + + _position = 0; + FieldChainPattern? pattern = TryParsePatternChain(); + + if (pattern == null) + { + throw new PatternFormatException(_source, _position, "Pattern is empty."); + } + + return pattern; + } + + private void EnqueueTokens() + { + _tokenQueue = new Queue(); + _position = 0; + + foreach (char character in _source) + { + if (CharToTokenTable.TryGetValue(character, out Token token)) + { + _tokenQueue.Enqueue(token); + } + else + { + throw new PatternFormatException(_source, _position, $"Unknown token '{character}'."); + } + + _position++; + } + } + + private FieldChainPattern? TryParsePatternChain() + { + if (_tokenQueue.Count == 0) + { + return null; + } + + FieldTypes choices = ParseTypeOrSet(); + (bool atLeastOne, bool atMostOne) = ParseQuantifier(); + FieldChainPattern? next = TryParsePatternChain(); + + return new FieldChainPattern(choices, atLeastOne, atMostOne, next); + } + + private FieldTypes ParseTypeOrSet() + { + bool isChoiceSet = TryEatToken(static token => token == Token.BracketOpen) != null; + FieldTypes choices = EatFieldType(isChoiceSet ? "Field type expected." : "Field type or [ expected."); + + if (isChoiceSet) + { + FieldTypes? extraChoice; + + while ((extraChoice = TryEatFieldType()) != null) + { + choices |= extraChoice.Value; + } + + EatToken(static token => token == Token.BracketClose, "Field type or ] expected."); + } + + return choices; + } + + private (bool atLeastOne, bool atMostOne) ParseQuantifier() + { + Token? quantifier = TryEatToken(static token => QuantifierTokens.Contains(token)); + + return quantifier switch + { + Token.QuestionMark => (false, true), + Token.Plus => (true, false), + Token.Asterisk => (false, false), + _ => (true, true) + }; + } + + private FieldTypes EatFieldType(string errorMessage) + { + FieldTypes? fieldType = TryEatFieldType(); + + if (fieldType != null) + { + return fieldType.Value; + } + + throw new PatternFormatException(_source, _position, errorMessage); + } + + private FieldTypes? TryEatFieldType() + { + Token? token = TryEatToken(static token => TokenToFieldTypeTable.ContainsKey(token)); + + if (token != null) + { + return TokenToFieldTypeTable[token.Value]; + } + + return null; + } + + private void EatToken(Predicate condition, string errorMessage) + { + Token? token = TryEatToken(condition); + + if (token == null) + { + throw new PatternFormatException(_source, _position, errorMessage); + } + } + + private Token? TryEatToken(Predicate condition) + { + if (_tokenQueue.TryPeek(out Token nextToken) && condition(nextToken)) + { + _tokenQueue.Dequeue(); + _position++; + return nextToken; + } + + return null; + } + + private enum Token + { + QuestionMark, + Plus, + Asterisk, + BracketOpen, + BracketClose, + ToManyRelationship, + ToOneRelationship, + Relationship, + Attribute, + Field + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs new file mode 100644 index 0000000000..6e12ad8481 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -0,0 +1,85 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into text. +/// +internal sealed class PatternTextFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternTextFormatter(FieldChainPattern pattern) + { + ArgumentGuard.NotNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteChoices(current.Choices, builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteChoices(FieldTypes types, StringBuilder builder) + { + int startOffset = builder.Length; + + if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('M'); + } + + if (types.HasFlag(FieldTypes.ToOneRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('O'); + } + + if (types.HasFlag(FieldTypes.Attribute) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('A'); + } + + if (types.HasFlag(FieldTypes.Relationship) && !types.HasFlag(FieldTypes.Field)) + { + builder.Append('R'); + } + + if (types.HasFlag(FieldTypes.Field)) + { + builder.Append('F'); + } + + int charCount = builder.Length - startOffset; + + if (charCount > 1) + { + builder.Insert(startOffset, '['); + builder.Append(']'); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? '?' : '*'); + } + else if (!atMostOne) + { + builder.Append('+'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs similarity index 78% rename from src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index dace5b8ca4..fe8d35bdca 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -6,47 +6,37 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader { private static readonly LegacyFilterNotationConverter LegacyConverter = new(); private readonly IJsonApiOptions _options; - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly FilterParser _filterParser; + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly IFilterParser _filterParser; private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); private readonly Dictionary.Builder> _filtersPerScope = new(); - private string? _lastParameterName; - public bool AllowEmptyValue => false; - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) + public FilterQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, IFilterParser filterParser, IJsonApiRequest request, + IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) { + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(filterParser); ArgumentGuard.NotNull(options); _options = options; - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceFactory, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsFilterBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; - - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Filtering on the requested {kind} is not allowed.", - $"Filtering on {kind} '{field.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _filterParser = filterParser; } /// @@ -69,8 +59,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) { ReadSingleValue(parameterName, value); @@ -97,6 +85,8 @@ private IEnumerable ExtractParameterValue(string? parameterValue) private void ReadSingleValue(string parameterName, string parameterValue) { + bool parameterNameIsValid = false; + try { string name = parameterName; @@ -108,19 +98,25 @@ private void ReadSingleValue(string parameterName, string parameterValue) } ResourceFieldChainExpression? scope = GetScope(name); - FilterExpression filter = GetFilter(value, scope); + parameterNameIsValid = true; + FilterExpression filter = GetFilter(value, scope); StoreFilterInScope(filter, scope); } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); + string specificMessage = _options.EnableLegacyFilterNotation + ? exception.Message + : exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue : parameterName); + + throw new InvalidQueryStringParameterException(parameterName, "The specified filter is invalid.", specificMessage, exception); } } private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); if (parameterScope.Scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs similarity index 76% rename from src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs index 7db9a9a7d7..8540b4dddb 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -5,28 +5,27 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { - private readonly IJsonApiOptions _options; - private readonly IncludeParser _includeParser; + private readonly IIncludeParser _includeParser; private IncludeExpression? _includeExpression; public bool AllowEmptyValue => true; - public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + public IncludeQueryStringParameterReader(IIncludeParser includeParser, IJsonApiRequest request, IResourceGraph resourceGraph) : base(request, resourceGraph) { - ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(includeParser); - _options = options; - _includeParser = new IncludeParser(); + _includeParser = includeParser; } /// @@ -52,13 +51,14 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterValue); + throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", specificMessage, exception); } } private IncludeExpression GetInclude(string parameterValue) { - return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); + return _includeParser.Parse(parameterValue, RequestResourceType); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs similarity index 60% rename from src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs rename to src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs index 259e4c70f1..0480158133 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; [PublicAPI] public sealed class LegacyFilterNotationConverter @@ -10,27 +10,23 @@ public sealed class LegacyFilterNotationConverter private const string ParameterNameSuffix = "]"; private const string OutputParameterName = "filter"; - private const string ExpressionPrefix = "expr:"; - private const string NotEqualsPrefix = "ne:"; - private const string InPrefix = "in:"; - private const string NotInPrefix = "nin:"; - private static readonly Dictionary PrefixConversionTable = new() { - ["eq:"] = Keywords.Equals, - ["lt:"] = Keywords.LessThan, - ["le:"] = Keywords.LessOrEqual, - ["gt:"] = Keywords.GreaterThan, - ["ge:"] = Keywords.GreaterOrEqual, - ["like:"] = Keywords.Contains + [ParameterValuePrefix.Equal] = Keywords.Equals, + [ParameterValuePrefix.LessThan] = Keywords.LessThan, + [ParameterValuePrefix.LessOrEqual] = Keywords.LessOrEqual, + [ParameterValuePrefix.GreaterThan] = Keywords.GreaterThan, + [ParameterValuePrefix.GreaterEqual] = Keywords.GreaterOrEqual, + [ParameterValuePrefix.Like] = Keywords.Contains }; public IEnumerable ExtractConditions(string parameterValue) { ArgumentGuard.NotNullNorEmpty(parameterValue); - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal) || parameterValue.StartsWith(InPrefix, StringComparison.Ordinal) || - parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) { yield return parameterValue; } @@ -48,9 +44,9 @@ public IEnumerable ExtractConditions(string parameterValue) ArgumentGuard.NotNullNorEmpty(parameterName); ArgumentGuard.NotNullNorEmpty(parameterValue); - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal)) { - string expression = parameterValue[ExpressionPrefix.Length..]; + string expression = parameterValue[ParameterValuePrefix.Expression.Length..]; return (parameterName, expression); } @@ -68,40 +64,40 @@ public IEnumerable ExtractConditions(string parameterValue) } } - if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.NotEqual, StringComparison.Ordinal)) { - string value = parameterValue[NotEqualsPrefix.Length..]; + string value = parameterValue[ParameterValuePrefix.NotEqual.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; return (OutputParameterName, expression); } - if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal)) { - string[] valueParts = parameterValue[InPrefix.Length..].Split(","); + string[] valueParts = parameterValue[ParameterValuePrefix.In.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Any}({attributeName},{valueList})"; return (OutputParameterName, expression); } - if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + if (parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) { - string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); + string[] valueParts = parameterValue[ParameterValuePrefix.NotIn.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; return (OutputParameterName, expression); } - if (parameterValue == "isnull:") + if (parameterValue == ParameterValuePrefix.IsNull) { string expression = $"{Keywords.Equals}({attributeName},null)"; return (OutputParameterName, expression); } - if (parameterValue == "isnotnull:") + if (parameterValue == ParameterValuePrefix.IsNotNull) { string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; return (OutputParameterName, expression); @@ -128,11 +124,27 @@ private static string ExtractAttributeName(string parameterName) } } - throw new QueryParseException("Expected field name between brackets in filter parameter name."); + throw new QueryParseException("Expected field name between brackets in filter parameter name.", -1); } private static string EscapeQuotes(string text) { return text.Replace("'", "''"); } + + private sealed class ParameterValuePrefix + { + public const string Equal = "eq:"; + public const string NotEqual = "ne:"; + public const string LessThan = "lt:"; + public const string LessOrEqual = "le:"; + public const string GreaterThan = "gt:"; + public const string GreaterEqual = "ge:"; + public const string Like = "like:"; + public const string In = "in:"; + public const string NotIn = "nin:"; + public const string IsNull = "isnull:"; + public const string IsNotNull = "isnotnull:"; + public const string Expression = "expr:"; + } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs similarity index 74% rename from src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index c6ec12afb6..da6f6048ca 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -6,11 +6,12 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader { @@ -18,20 +19,22 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, private const string PageNumberParameterName = "page[number]"; private readonly IJsonApiOptions _options; - private readonly PaginationParser _paginationParser; + private readonly IPaginationParser _paginationParser; private PaginationQueryStringValueExpression? _pageSizeConstraint; private PaginationQueryStringValueExpression? _pageNumberConstraint; public bool AllowEmptyValue => false; - public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + public PaginationQueryStringParameterReader(IPaginationParser paginationParser, IJsonApiRequest request, IResourceGraph resourceGraph, + IJsonApiOptions options) : base(request, resourceGraph) { + ArgumentGuard.NotNull(paginationParser); ArgumentGuard.NotNull(options); _options = options; - _paginationParser = new PaginationParser(); + _paginationParser = paginationParser; } /// @@ -51,13 +54,17 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { + bool isParameterNameValid = true; + try { PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue.ToString()); if (constraint.Elements.Any(element => element.Scope == null)) { + isParameterNameValid = false; AssertIsCollectionRequest(); + isParameterNameValid = true; } if (parameterName == PageSizeParameterName) @@ -73,7 +80,8 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(isParameterNameValid ? parameterValue : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", specificMessage, exception); } } @@ -84,36 +92,44 @@ private PaginationQueryStringValueExpression GetPageConstraint(string parameterV protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) { - if (_options.MaximumPageSize != null) + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) { - if (constraint.Elements.Any(element => element.Value > _options.MaximumPageSize.Value)) + if (_options.MaximumPageSize != null) { - throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}."); + if (element.Value > _options.MaximumPageSize.Value) + { + throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}.", element.Position); + } + + if (element.Value == 0) + { + throw new QueryParseException("Page size cannot be unconstrained.", element.Position); + } } - if (constraint.Elements.Any(element => element.Value == 0)) + if (element.Value < 0) { - throw new QueryParseException("Page size cannot be unconstrained."); + throw new QueryParseException("Page size cannot be negative.", element.Position); } } - - if (constraint.Elements.Any(element => element.Value < 0)) - { - throw new QueryParseException("Page size cannot be negative."); - } } - [AssertionMethod] protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) { - if (_options.MaximumPageNumber != null && constraint.Elements.Any(element => element.Value > _options.MaximumPageNumber.OneBasedValue)) + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) { - throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}."); - } + if (_options.MaximumPageNumber != null) + { + if (element.Value > _options.MaximumPageNumber.OneBasedValue) + { + throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}.", element.Position); + } + } - if (constraint.Elements.Any(element => element.Value < 1)) - { - throw new QueryParseException("Page number cannot be negative or zero."); + if (element.Value < 1) + { + throw new QueryParseException("Page number cannot be negative or zero.", element.Position); + } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs index 656cbff0cb..d3f5277881 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs @@ -1,10 +1,10 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; public abstract class QueryStringParameterReader { @@ -47,7 +47,7 @@ protected void AssertIsCollectionRequest() { if (!_isCollectionRequest) { - throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource)."); + throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource).", 0); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs rename to src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs index b5dda40498..ceb58df45b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs @@ -1,4 +1,3 @@ -using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Diagnostics; @@ -6,11 +5,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// -[PublicAPI] -public class QueryStringReader : IQueryStringReader +public sealed class QueryStringReader : IQueryStringReader { private readonly IJsonApiOptions _options; private readonly IRequestQueryStringAccessor _queryStringAccessor; @@ -32,7 +30,7 @@ public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor qu } /// - public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) + public void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs similarity index 93% rename from src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs index 2678627d1c..7b4351aece 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs similarity index 98% rename from src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index de02589807..816d9e7f3f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -7,7 +7,7 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs similarity index 67% rename from src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 5e5842c960..a335a0eca0 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -5,36 +5,31 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader { - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly SortParser _sortParser; + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly ISortParser _sortParser; private readonly List _constraints = new(); - private string? _lastParameterName; public bool AllowEmptyValue => false; - public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + public SortQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, ISortParser sortParser, IJsonApiRequest request, + IResourceGraph resourceGraph) : base(request, resourceGraph) { - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(ValidateSingleField); - } + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(sortParser); - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _sortParser = sortParser; } /// @@ -57,25 +52,28 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; + bool parameterNameIsValid = false; try { ResourceFieldChainExpression? scope = GetScope(parameterName); - SortExpression sort = GetSort(parameterValue.ToString(), scope); + parameterNameIsValid = true; + SortExpression sort = GetSort(parameterValue.ToString(), scope); var expressionInScope = new ExpressionInScope(scope, sort); _constraints.Add(expressionInScope); } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", specificMessage, exception); } } private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); if (parameterScope.Scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs similarity index 64% rename from src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index 09c3c0ede8..f557dea1c9 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -6,43 +6,35 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; +/// [PublicAPI] public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { - private readonly SparseFieldTypeParser _sparseFieldTypeParser; - private readonly SparseFieldSetParser _sparseFieldSetParser; + private readonly ISparseFieldTypeParser _scopeParser; + private readonly ISparseFieldSetParser _sparseFieldSetParser; private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = ImmutableDictionary.CreateBuilder(); - private string? _lastParameterName; - /// bool IQueryStringParameterReader.AllowEmptyValue => true; - public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + public SparseFieldSetQueryStringParameterReader(ISparseFieldTypeParser scopeParser, ISparseFieldSetParser sparseFieldSetParser, IJsonApiRequest request, + IResourceGraph resourceGraph) : base(request, resourceGraph) { - _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsViewBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; + ArgumentGuard.NotNull(scopeParser); + ArgumentGuard.NotNull(sparseFieldSetParser); - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Retrieving the requested {kind} is not allowed.", - $"Retrieving the {kind} '{field.PublicName}' is not allowed."); - } + _scopeParser = scopeParser; + _sparseFieldSetParser = sparseFieldSetParser; } /// @@ -64,24 +56,26 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; + bool parameterNameIsValid = false; try { - ResourceType targetResourceType = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), targetResourceType); + ResourceType resourceType = GetScope(parameterName); + parameterNameIsValid = true; - _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), resourceType); + _sparseFieldTableBuilder[resourceType] = sparseFieldSet; } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", specificMessage, exception); } } - private ResourceType GetSparseFieldType(string parameterName) + private ResourceType GetScope(string parameterName) { - return _sparseFieldTypeParser.Parse(parameterName); + return _scopeParser.Parse(parameterName); } private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) @@ -90,7 +84,7 @@ private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, Resour if (sparseFieldSet == null) { - // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. + // We add ID to an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 6b36d9d84c..e04022be95 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -136,11 +136,12 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) source = queryableHandler.Apply(source); } - var nameFactory = new LambdaParameterNameFactory(); - - var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); +#pragma warning disable CS0618 + IQueryableBuilder builder = _resourceDefinitionAccessor.QueryableBuilder; +#pragma warning restore CS0618 - Expression expression = builder.ApplyQuery(queryLayer); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Queryable), _dbContext.Model, null); + Expression expression = builder.ApplyQuery(queryLayer, context); using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) { diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index df0061a5aa..d16a6074b2 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources; @@ -20,6 +21,15 @@ public interface IResourceDefinitionAccessor [Obsolete("Use IJsonApiRequest.IsReadOnly.")] bool IsReadOnlyRequest { get; } + /// + /// Gets an instance from the service container. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use injected IQueryableBuilder instead.")] + public IQueryableBuilder QueryableBuilder { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 9a1c025214..34af2f36fe 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,5 +1,4 @@ using System.Reflection; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index fa693d205c..08d6537cbc 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -63,7 +63,7 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl /// }); /// ]]> /// - protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + protected virtual SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { ArgumentGuard.NotNullNorEmpty(keySelectors); diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 6b7ac6625b..4ebe5cf453 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +26,9 @@ public bool IsReadOnlyRequest } } + /// + public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { ArgumentGuard.NotNull(resourceGraph); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 27ddc317d8..80aaa2c328 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 7550cbf761..4552e46e5b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -33,6 +33,7 @@ public class LinkBuilder : ILinkBuilder private readonly IHttpContextAccessor _httpContextAccessor; private readonly LinkGenerator _linkGenerator; private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IPaginationParser _paginationParser; private HttpContext HttpContext { @@ -48,13 +49,14 @@ private HttpContext HttpContext } public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, - LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser) { ArgumentGuard.NotNull(options); ArgumentGuard.NotNull(request); ArgumentGuard.NotNull(paginationContext); ArgumentGuard.NotNull(linkGenerator); ArgumentGuard.NotNull(controllerResourceMapping); + ArgumentGuard.NotNull(paginationParser); _options = options; _request = request; @@ -62,6 +64,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination _httpContextAccessor = httpContextAccessor; _linkGenerator = linkGenerator; _controllerResourceMapping = controllerResourceMapping; + _paginationParser = paginationParser; } private static string NoAsyncSuffix(string actionName) @@ -153,7 +156,7 @@ private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLin if (topPageSize != null) { - var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); + var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value, -1); elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); } @@ -178,10 +181,9 @@ private IImmutableList ParsePageSiz return ImmutableArray.Empty; } - var parser = new PaginationParser(); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); + PaginationQueryStringValueExpression pagination = _paginationParser.Parse(pageSizeParameterValue, resourceType); - return paginationExpression.Elements; + return pagination.Elements; } private string GetLinkForPagination(int pageOffset, string? pageSizeValue) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index b1398b7cfb..ceba6d2285 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -5,12 +5,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Response; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 925cd2e551..fe94e8f073 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -128,7 +128,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Includes_version_with_ext_on_error_in_operations_endpoint() + public async Task Includes_version_with_ext_on_error_at_operations_endpoint() { // Arrange string musicTrackId = Unknown.StringId.For(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index efeb369dc4..392a76c08d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -29,7 +29,7 @@ public AtomicQueryStringTests(IntegrationTestContext(); @@ -299,7 +299,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + public async Task Cannot_use_Queryable_handler_at_operations_endpoint() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 2c1e378e77..6d887a3e9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -117,10 +117,12 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg { if (IsSortOnCarId(sortElement)) { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); + var fieldChain = (ResourceFieldChainExpression)sortElement.Target; + + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(fieldChain, _regionIdAttribute); elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(fieldChain, _licensePlateAttribute); elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); } else @@ -134,9 +136,9 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg private static bool IsSortOnCarId(SortElementExpression sortElement) { - if (sortElement.TargetAttribute != null) + if (sortElement.Target is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) { - PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property; + PropertyInfo property = attribute.Property; if (IsCarId(property)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index e9ed2f789c..031ed80fce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -45,6 +45,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } + [Fact] + public async Task Cannot_filter_equality_for_invalid_ID() + { + // Arrange + var parameterValue = new MarkedText("equals(id,^'not-a-hex-value')", '^'); + string route = $"/bankAccounts?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The value 'not-a-hex-value' is not a valid hexadecimal value. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Fact] public async Task Can_filter_any_in_primary_resources() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 6cd623ac94..ec3d49fe7a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -87,7 +87,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + public async Task Cannot_clear_required_OneToOne_relationship_at_primary_endpoint() { // Arrange SystemVolume existingVolume = _fakers.SystemVolume.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index ff3360be30..8bfb268daa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -31,7 +31,7 @@ public TopLevelCountTests(IntegrationTestContext, } [Fact] - public async Task Renders_resource_count_for_primary_resources_endpoint_with_filter() + public async Task Renders_resource_count_at_primary_resources_endpoint_with_filter() { // Arrange List tickets = _fakers.SupportTicket.Generate(2); @@ -57,7 +57,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Renders_resource_count_for_secondary_resources_endpoint_with_filter() + public async Task Renders_resource_count_at_secondary_resources_endpoint_with_filter() { // Arrange ProductFamily family = _fakers.ProductFamily.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 2805745644..36a93f9ee3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -19,4 +19,7 @@ public sealed class Appointment : Identifiable [Attr] public DateTimeOffset EndTime { get; set; } + + [HasMany] + public IList Reminders { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs index 6c8baa0a97..84360c8862 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs @@ -14,6 +14,9 @@ public sealed class Comment : Identifiable [Attr] public DateTime CreatedAt { get; set; } + [Attr] + public int NumStars { get; set; } + [HasOne] public WebAccount? Author { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs new file mode 100644 index 0000000000..bea7ccd2ba --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs @@ -0,0 +1,83 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +/// +/// This expression allows to test if the value of a JSON:API attribute is upper case. It represents the "isUpperCase" filter function, resulting from +/// text such as: +/// +/// isUpperCase(title) +/// +/// , or: +/// +/// isUpperCase(owner.lastName) +/// +/// . +/// +internal sealed class IsUpperCaseExpression : FilterExpression +{ + public const string Keyword = "isUpperCase"; + + /// + /// The string attribute whose value to inspect. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + public IsUpperCaseExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentGuard.NotNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (IsUpperCaseExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs new file mode 100644 index 0000000000..aca362aed3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public IsUpperCaseFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new IsUpperCaseFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "isUpperCase^", "( expected.")] + [InlineData("filter", "isUpperCase(^", "Field name expected.")] + [InlineData("filter", "isUpperCase(^ ", "Unexpected whitespace.")] + [InlineData("filter", "isUpperCase(^)", "Field name expected.")] + [InlineData("filter", "isUpperCase(^'a')", "Field name expected.")] + [InlineData("filter", "isUpperCase(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^null)", "Field name expected.")] + [InlineData("filter", "isUpperCase(title)^)", "End of expression expected.")] + [InlineData("filter", "isUpperCase(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "isUpperCase(title)", null)] + [InlineData("filter", "isUpperCase(owner.userName)", null)] + [InlineData("filter", "has(posts,isUpperCase(author.userName))", null)] + [InlineData("filter", "or(isUpperCase(title),isUpperCase(platformName))", null)] + [InlineData("filter[posts]", "isUpperCase(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs new file mode 100644 index 0000000000..5debe54835 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -0,0 +1,48 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseFilterParser : FilterParser +{ + public IsUpperCaseFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: IsUpperCaseExpression.Keyword }) + { + return ParseIsUpperCase(); + } + + return base.ParseFilter(); + } + + private IsUpperCaseExpression ParseIsUpperCase() + { + EatText(IsUpperCaseExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsUpperCaseExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs new file mode 100644 index 0000000000..a61c8ea744 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public IsUpperCaseFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_casing_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant(); + blog.Posts[0].Url = blog.Posts[0].Url.ToUpperInvariant(); + + blog.Posts[1].Caption = blog.Posts[1].Caption.ToUpperInvariant(); + blog.Posts[1].Url = blog.Posts[1].Url.ToLowerInvariant(); + + blog.Posts[2].Caption = blog.Posts[2].Caption.ToLowerInvariant(); + blog.Posts[2].Url = blog.Posts[2].Url.ToLowerInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = blogs[1].Posts[0].Caption.ToLowerInvariant(); + blogs[1].Posts[1].Caption = blogs[1].Posts[1].Caption.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)&include=posts&filter[posts]=isUpperCase(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs new file mode 100644 index 0000000000..691a5aacde --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo ToUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is IsUpperCaseExpression isUpperCaseExpression) + { + return VisitIsUpperCase(isUpperCaseExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitIsUpperCase(IsUpperCaseExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + MethodCallExpression toUpperMethodCall = Expression.Call(propertyAccess, ToUpperMethod); + + return Expression.Equal(propertyAccess, toUpperMethodCall); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs new file mode 100644 index 0000000000..727d3ec808 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs @@ -0,0 +1,87 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +/// +/// This expression allows to determine the string length of a JSON:API attribute. It represents the "length" function, resulting from text such as: +/// +/// length(title) +/// +/// , or: +/// +/// length(owner.lastName) +/// +/// . +/// +internal sealed class LengthExpression : FunctionExpression +{ + public const string Keyword = "length"; + + /// + /// The string attribute whose length to determine. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + + public LengthExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentGuard.NotNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (LengthExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs new file mode 100644 index 0000000000..78352a6ab7 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public LengthFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(length^", "( expected.")] + [InlineData("filter", "equals(length(^", "Field name expected.")] + [InlineData("filter", "equals(length(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(length(^)", "Field name expected.")] + [InlineData("filter", "equals(length(^'a')", "Field name expected.")] + [InlineData("filter", "equals(length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^null)", "Field name expected.")] + [InlineData("filter", "equals(length(title)^)", ", expected.")] + [InlineData("filter", "equals(length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "equals(length(title),'1')", null)] + [InlineData("filter", "greaterThan(length(owner.userName),'1')", null)] + [InlineData("filter", "has(posts,lessThan(length(author.userName),'1'))", null)] + [InlineData("filter", "or(equals(length(title),'1'),equals(length(platformName),'1'))", null)] + [InlineData("filter[posts]", "equals(length(author.userName),'1')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs new file mode 100644 index 0000000000..028d40c0ed --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -0,0 +1,58 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthFilterParser : FilterParser +{ + public LengthFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(); + } + + return base.ParseFunction(); + } + + private LengthExpression ParseLength() + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs new file mode 100644 index 0000000000..9954a0925e --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=greaterThan(length(title),'2')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=greaterThan(length(caption),length(url))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = "Y"; + blogs[1].Posts[1].Caption = "YYY"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(length(title),'3')&include=posts&filter[posts]=equals(length(caption),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs new file mode 100644 index 0000000000..4ea6068173 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthOrderClauseBuilder : OrderClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs new file mode 100644 index 0000000000..c28bec8c8a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortParseTests : BaseParseTests +{ + private readonly SortQueryStringParameterReader _reader; + + public LengthSortParseTests() + { + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthSortParser(); + + _reader = new SortQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph); + } + + [Theory] + [InlineData("sort", "length^", "( expected.")] + [InlineData("sort", "length(^", "Field name expected.")] + [InlineData("sort", "length(^ ", "Unexpected whitespace.")] + [InlineData("sort", "length(^)", "Field name expected.")] + [InlineData("sort", "length(^'a')", "Field name expected.")] + [InlineData("sort", "length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^null)", "Field name expected.")] + [InlineData("sort", "length(title)^)", ", expected.")] + [InlineData("sort", "length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("sort", "length(title)", null)] + [InlineData("sort", "length(title),-length(platformName)", null)] + [InlineData("sort", "length(owner.userName)", null)] + [InlineData("sort[posts]", "length(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs new file mode 100644 index 0000000000..6b24d4d4bc --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthSortParser : SortParser +{ + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction(ResourceType resourceType) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(resourceType); + } + + return base.ParseFunction(resourceType); + } + + private LengthExpression ParseLength(ResourceType resourceType) + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, + FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs new file mode 100644 index 0000000000..46c0a68a07 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthSortTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_sort_on_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=-length(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?sort=length(caption),length(url)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[2].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); + + responseDocument.Data.ManyValue[2].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[2].Id.Should().Be(blog.Posts[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.Generate(2); + blogs[0].Title = "XXX"; + blogs[1].Title = "X"; + + blogs[1].Posts = _fakers.BlogPost.Generate(2); + blogs[1].Posts[0].Caption = "YYY"; + blogs[1].Posts[1].Caption = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=length(title)&include=posts&sort[posts]=length(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Posts[0].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs new file mode 100644 index 0000000000..6d553bdd41 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs new file mode 100644 index 0000000000..7e137ad3d7 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs @@ -0,0 +1,98 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +/// +/// This expression allows to determine the sum of values in the related resources of a to-many relationship. It represents the "sum" function, resulting +/// from text such as: +/// +/// sum(orderLines,quantity) +/// +/// , or: +/// +/// sum(friends,count(children)) +/// +/// . +/// +internal sealed class SumExpression : FunctionExpression +{ + public const string Keyword = "sum"; + + /// + /// The to-many relationship whose related resources are summed over. + /// + public ResourceFieldChainExpression TargetToManyRelationship { get; } + + /// + /// The selector to apply on related resources, which can be a function or a field chain. Chain format: an optional list of to-one relationships, + /// followed by an attribute. The selector must return a numeric type. + /// + public QueryExpression Selector { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(ulong); + + public SumExpression(ResourceFieldChainExpression targetToManyRelationship, QueryExpression selector) + { + ArgumentGuard.NotNull(targetToManyRelationship); + ArgumentGuard.NotNull(selector); + + TargetToManyRelationship = targetToManyRelationship; + Selector = selector; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship); + builder.Append(','); + builder.Append(toFullString ? Selector.ToFullString() : Selector); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (SumExpression)obj; + + return TargetToManyRelationship.Equals(other.TargetToManyRelationship) && Selector.Equals(other.Selector); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToManyRelationship, Selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs new file mode 100644 index 0000000000..c2cc62e279 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public SumFilterParseTests() + { + var resourceFactory = new ResourceFactory(new ServiceContainer()); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new SumFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(sum^", "( expected.")] + [InlineData("filter", "equals(sum(^", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(sum(^)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^'a')", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^null)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(sum(^title)", + "Field chain on resource type 'blogs' failed to match the pattern: a to-many relationship. " + + "To-many relationship on resource type 'blogs' expected.")] + [InlineData("filter", "equals(sum(posts^))", ", expected.")] + [InlineData("filter", "equals(sum(posts,^))", "Field name expected.")] + [InlineData("filter", "equals(sum(posts,author^))", + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " + + "To-one relationship or attribute on resource type 'webAccounts' expected.")] + [InlineData("filter", "equals(sum(posts,^url))", "Attribute of a numeric type expected.")] + [InlineData("filter", "equals(sum(posts,^has(labels)))", "Function that returns a numeric type expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "has(posts,greaterThan(sum(comments,numStars),'5'))", null)] + [InlineData("filter[posts]", "equals(sum(comments,numStars),'11')", "posts")] + [InlineData("filter[posts]", "equals(sum(labels,count(posts)),'8')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs new file mode 100644 index 0000000000..0668d32644 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -0,0 +1,112 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumFilterParser : FilterParser +{ + private static readonly FieldChainPattern SingleToManyRelationshipChain = FieldChainPattern.Parse("M"); + + private static readonly HashSet NumericTypes = new(new[] + { + typeof(sbyte), + typeof(byte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal) + }); + + public SumFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == SumExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: SumExpression.Keyword }) + { + return ParseSum(); + } + + return base.ParseFunction(); + } + + private SumExpression ParseSum() + { + EatText(SumExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, "To-many relationship expected."); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new SumExpression(targetToManyRelationshipChain, selector); + } + + private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain) + { + var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); + + using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + return ParseSumSelector(); + } + + private QueryExpression ParseSumSelector() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + FunctionExpression function = ParseFunction(); + + if (!IsNumericType(function.ReturnType)) + { + throw new QueryParseException("Function that returns a numeric type expected.", position); + } + + return function; + } + + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, null); + + var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; + + if (!IsNumericType(attrAttribute.Property.PropertyType)) + { + throw new QueryParseException("Attribute of a numeric type expected.", position); + } + + return fieldChain; + } + + private static bool IsNumericType(Type type) + { + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + return NumericTypes.Contains(innerType); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs new file mode 100644 index 0000000000..f558fbb36b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs @@ -0,0 +1,141 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public SumFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_sum_at_primary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.Generate(2); + + posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[0].Comments.ElementAt(0).NumStars = 0; + posts[0].Comments.ElementAt(1).NumStars = 1; + + posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 3; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=greaterThan(sum(comments,numStars),'4')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_on_count_at_secondary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.Generate(2); + + posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[0].Comments.ElementAt(0).NumStars = 1; + posts[0].Comments.ElementAt(1).NumStars = 1; + posts[0].Contributors = _fakers.Woman.Generate(1).OfType().ToHashSet(); + + posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 2; + posts[1].Contributors = _fakers.Man.Generate(2).OfType().ToHashSet(); + posts[1].Contributors.ElementAt(0).Children = _fakers.Woman.Generate(3).OfType().ToHashSet(); + posts[1].Contributors.ElementAt(1).Children = _fakers.Man.Generate(3).OfType().ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=lessThan(sum(comments,numStars),sum(contributors,count(children)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_in_included_resources() + { + // Arrange + Blog blog = _fakers.Blog.Generate(); + blog.Posts = _fakers.BlogPost.Generate(2); + + blog.Posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blog.Posts[0].Comments.ElementAt(0).NumStars = 1; + blog.Posts[0].Comments.ElementAt(1).NumStars = 1; + + blog.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blog.Posts[1].Comments.ElementAt(0).NumStars = 1; + blog.Posts[1].Comments.ElementAt(1).NumStars = 2; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?include=posts&filter[posts]=equals(sum(comments,numStars),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.StringId); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blog.Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs new file mode 100644 index 0000000000..a732490123 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumWhereClauseBuilder : WhereClauseBuilder +{ + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is SumExpression sumExpression) + { + return VisitSum(sumExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private Expression VisitSum(SumExpression expression, QueryClauseBuilderContext context) + { + Expression collectionPropertyAccess = Visit(expression.TargetToManyRelationship, context); + + ResourceType selectorResourceType = ((HasManyAttribute)expression.TargetToManyRelationship.Fields.Single()).RightType; + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(selectorResourceType.ClrType); + + var nestedContext = new QueryClauseBuilderContext(collectionPropertyAccess, selectorResourceType, typeof(Enumerable), context.EntityModel, + context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); + + LambdaExpression lambda = GetSelectorLambda(expression.Selector, nestedContext); + + return SumExtensionMethodCall(lambda, nestedContext); + } + + private LambdaExpression GetSelectorLambda(QueryExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); + } + + private static Expression SumExtensionMethodCall(LambdaExpression selector, QueryClauseBuilderContext context) + { + return Expression.Call(context.ExtensionType, "Sum", context.LambdaScope.Parameter.Type.AsArray(), context.Source, selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs new file mode 100644 index 0000000000..138ccdeafd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs @@ -0,0 +1,30 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class FilterRewritingResourceDefinition : JsonApiResourceDefinition + where TResource : class, IIdentifiable +{ + private readonly FilterTimeOffsetRewriter _rewriter; + + public FilterRewritingResourceDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + _rewriter = new FilterTimeOffsetRewriter(systemClock); + } + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (existingFilter != null) + { + return (FilterExpression)_rewriter.Visit(existingFilter, null)!; + } + + return existingFilter; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs new file mode 100644 index 0000000000..8adc07fdf0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs @@ -0,0 +1,44 @@ +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class FilterTimeOffsetRewriter : QueryExpressionRewriter +{ + private static readonly Dictionary InverseComparisonOperatorTable = new() + { + [ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan, + [ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual, + [ComparisonOperator.Equals] = ComparisonOperator.Equals, + [ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan, + [ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual + }; + + private readonly ISystemClock _systemClock; + + public FilterTimeOffsetRewriter(ISystemClock systemClock) + { + _systemClock = systemClock; + } + + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + { + if (expression.Right is TimeOffsetExpression timeOffset) + { + DateTime currentTime = _systemClock.UtcNow.UtcDateTime; + + var offsetComparison = + new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator, + expression.Left, new LiteralConstantExpression(currentTime + timeOffset.Value)); + + ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual + ? new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual, + expression.Left, new LiteralConstantExpression(currentTime)) + : null; + + return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison); + } + + return base.VisitComparison(expression, argument); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs new file mode 100644 index 0000000000..110e91012c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs @@ -0,0 +1,92 @@ +using System.Text; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +/// +/// This expression wraps a time duration. It represents the "timeOffset" function, resulting from text such as: +/// +/// timeOffset('+0:10:00') +/// +/// , or: +/// +/// timeOffset('-0:10:00') +/// +/// . +/// +internal sealed class TimeOffsetExpression : FunctionExpression +{ + public const string Keyword = "timeOffset"; + + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. + private readonly LiteralConstantExpression _timeSpanConstant; + + /// + /// The time offset, which can be negative. + /// + public TimeSpan Value { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(TimeSpan); + + public TimeOffsetExpression(LiteralConstantExpression timeSpanConstant) + { + ArgumentGuard.NotNull(timeSpanConstant); + + if (timeSpanConstant.TypedValue.GetType() != typeof(TimeSpan)) + { + throw new ArgumentException($"Constant must contain a {nameof(TimeSpan)}.", nameof(timeSpanConstant)); + } + + _timeSpanConstant = timeSpanConstant; + + Value = (TimeSpan)timeSpanConstant.TypedValue; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(_timeSpanConstant); + builder.Append(')'); + + return builder.ToString(); + } + + public override string ToFullString() + { + return ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (TimeOffsetExpression)obj; + + return Value == other.Value; + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs new file mode 100644 index 0000000000..0bc330785f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs @@ -0,0 +1,90 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class TimeOffsetFilterParser : FilterParser +{ + public TimeOffsetFilterParser(IResourceFactory resourceFactory) + : base(resourceFactory) + { + } + + protected override bool IsFunction(string name) + { + if (name == TimeOffsetExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: TimeOffsetExpression.Keyword }) + { + return ParseTimeOffset(); + } + + return base.ParseFunction(); + } + + private TimeOffsetExpression ParseTimeOffset() + { + EatText(TimeOffsetExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + LiteralConstantExpression constant = ParseTimeSpanConstant(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new TimeOffsetExpression(constant); + } + + private LiteralConstantExpression ParseTimeSpanConstant() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + string value = token.Value!; + + if (value.Length > 1 && value[0] is '+' or '-') + { + TimeSpan timeSpan = ConvertStringToTimeSpan(value[1..], position); + TimeSpan timeOffset = value[0] == '-' ? -timeSpan : timeSpan; + + return new LiteralConstantExpression(timeOffset, value); + } + } + + throw new QueryParseException("Time offset between quotes expected.", position); + } + + private static TimeSpan ConvertStringToTimeSpan(string value, int position) + { + try + { + return (TimeSpan)RuntimeTypeConverter.ConvertType(value, typeof(TimeSpan))!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{nameof(TimeSpan)}'.", position, exception); + } + } + + protected override ComparisonExpression ParseComparison(string operatorName) + { + int position = GetNextTokenPositionOrEnd(); + ComparisonExpression comparison = base.ParseComparison(operatorName); + + if (comparison.Left is TimeOffsetExpression) + { + throw new QueryParseException("The 'timeOffset' function can only be used at the right side of comparisons.", position); + } + + return comparison; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs new file mode 100644 index 0000000000..3a171bdae0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -0,0 +1,233 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +public sealed class TimeOffsetTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public TimeOffsetTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddTransient(); + services.AddSingleton(); + services.AddScoped(typeof(IResourceDefinition<,>), typeof(FilterRewritingResourceDefinition<,>)); + }); + } + + [Theory] + [InlineData("-0:10:00", ComparisonOperator.GreaterThan, "0")] // more than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.GreaterOrEqual, "0,1")] // at least 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.Equals, "1")] // exactly 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessThan, "2,3")] // less than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessOrEqual, "1,2,3")] // at most 10 minutes ago + [InlineData("+0:10:00", ComparisonOperator.GreaterThan, "6")] // more than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.GreaterOrEqual, "5,6")] // at least 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.Equals, "5")] // in exactly 10 minutes + [InlineData("+0:10:00", ComparisonOperator.LessThan, "3,4")] // less than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.LessOrEqual, "3,4,5")] // at most 10 minutes in the future + public async Task Can_filter_comparison_on_relative_time(string filterValue, ComparisonOperator comparisonOperator, string matchingRowsExpected) + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + List reminders = _fakers.Reminder.Generate(7); + reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-15)).DateTime.AsUtc(); + reminders[1].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-10)).DateTime.AsUtc(); + reminders[2].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-5)).DateTime.AsUtc(); + reminders[3].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(0)).DateTime.AsUtc(); + reminders[4].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(5)).DateTime.AsUtc(); + reminders[5].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(10)).DateTime.AsUtc(); + reminders[6].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(15)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Reminders.AddRange(reminders); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/reminders?filter={comparisonOperator.ToString().Camelize()}(remindsAt,timeOffset('{filterValue.Replace("+", "%2B")}'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + int[] matchingRowIndices = matchingRowsExpected.Split(',').Select(int.Parse).ToArray(); + responseDocument.Data.ManyValue.ShouldHaveCount(matchingRowIndices.Length); + + foreach (int rowIndex in matchingRowIndices) + { + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == reminders[rowIndex].StringId); + } + } + + [Fact] + public async Task Cannot_filter_comparison_on_missing_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Time offset between quotes expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_invalid_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^'-*'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Failed to convert '*' of type 'String' to type 'TimeSpan'. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_relative_time_at_left_side() + { + // Arrange + var parameterValue = new MarkedText("^equals(timeOffset('-0:10:00'),remindsAt)", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The 'timeOffset' function can only be used at the right side of comparisons. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_any_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("any(remindsAt,^timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Value between quotes expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("startsWith(^remindsAt,timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_comparison_on_relative_time_in_nested_expression() + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + Calendar calendar = _fakers.Calendar.Generate(); + calendar.Appointments = _fakers.Appointment.Generate(2).ToHashSet(); + + calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = clock.UtcNow.DateTime.AsUtc(); + + calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(30)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,timeOffset('%2B0:30:00')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index b910b7e42e..9bacb8097e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -319,7 +319,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/filterableResources?filter=equals(someInt32,'ABC')"; + var parameterValue = new MarkedText("equals(someInt32,^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -332,7 +333,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index 1d4e1793d9..1bcf0563cf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -11,6 +11,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; public sealed class FilterDepthTests : IClassFixture, QueryStringDbContext>> { + private const string CollectionErrorMessage = "This query string parameter can only be used on a collection of resources (not on a single resource)."; + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -53,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_in_single_primary_resource() + public async Task Cannot_filter_in_primary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -77,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -110,7 +112,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_in_single_secondary_resource() + public async Task Cannot_filter_in_secondary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -134,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -315,7 +317,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_filter_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 69dd7ca706..bc3c70c59a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -700,7 +700,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_filter_text_match_on_non_string_value() { // Arrange - const string route = "/filterableResources?filter=contains(someInt32,'123')"; + var parameterValue = new MarkedText("contains(^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -713,7 +714,30 @@ public async Task Cannot_filter_text_match_on_non_string_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Attribute of type 'String' expected."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_nested_non_string_value() + { + // Arrange + var parameterValue = new MarkedText("contains(parent.parent.^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -833,7 +857,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_on_count() + public async Task Can_filter_equality_on_count_at_left_side() { // Arrange var resource = new FilterableResource @@ -864,11 +888,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } + [Fact] + public async Task Can_filter_equality_on_count_at_both_sides() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new() + { + Children = new List + { + new() + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.Add(resource); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/filterableResources?filter=equals(count(children),count(parent.children))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource.Children.ElementAt(0).StringId); + } + [Fact] public async Task Cannot_filter_on_count_with_incompatible_value() { // Arrange - const string route = "/filterableResources?filter=equals(count(children),'ABC')"; + var parameterValue = new MarkedText("equals(count(children),^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -881,7 +943,7 @@ public async Task Cannot_filter_on_count_with_incompatible_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -926,4 +988,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); } + + [Theory] + [InlineData("equals(and(equals(someString,'ABC'),equals(someInt32,'11')),'true')")] + [InlineData("equals(or(greaterThan(someInt32,'150'),equals(someEnum,'Tuesday')),'true')")] + [InlineData("equals(equals(someString,'ABC'),not(lessThan(someInt32,'10')))")] + public async Task Can_filter_nested_on_comparisons(string filterExpression) + { + // Arrange + var resource1 = new FilterableResource + { + SomeString = "ABC", + SomeInt32 = 11, + SomeEnum = DayOfWeek.Tuesday + }; + + var resource2 = new FilterableResource + { + SomeString = "XYZ", + SomeInt32 = 99, + SomeEnum = DayOfWeek.Saturday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource1, resource2); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterExpression}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 104f5a4f79..42eca44c0b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -28,7 +28,8 @@ public FilterTests(IntegrationTestContext, public async Task Cannot_filter_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?filter[{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -41,16 +42,17 @@ public async Task Cannot_filter_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterName}"); error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[{Unknown.Relationship}]"); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?filter[posts.{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[posts.^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -63,16 +65,17 @@ public async Task Cannot_filter_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterName}"); error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[posts.{Unknown.Relationship}]"); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_on_attribute_with_blocked_capability() { // Arrange - const string route = "/webAccounts?filter=equals(dateOfBirth,null)"; + var parameterValue = new MarkedText("equals(^dateOfBirth,null)", '^'); + string route = $"/webAccounts?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -84,8 +87,8 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested attribute is not allowed."); - error.Detail.Should().Be("Filtering on attribute 'dateOfBirth' is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on attribute 'dateOfBirth' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -94,7 +97,8 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() { // Arrange - const string route = "/calendars?filter=has(appointments)"; + var parameterValue = new MarkedText("has(^appointments)", '^'); + string route = $"/calendars?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -106,8 +110,8 @@ public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested relationship is not allowed."); - error.Detail.Should().Be("Filtering on relationship 'appointments' is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on relationship 'appointments' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index 52b0ddbdd9..6a8ddc688f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -95,6 +95,9 @@ public sealed class FilterableResource : Identifiable [Attr] public DayOfWeek? SomeNullableEnum { get; set; } + [HasOne] + public FilterableResource? Parent { get; set; } + [HasMany] public ICollection Children { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 88360bcfa2..3a5a67097d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -256,7 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_include_ManyToMany_relationship_on_secondary_endpoint() + public async Task Can_include_ManyToMany_relationship_at_secondary_endpoint() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -853,7 +853,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_include_unknown_relationship() { // Arrange - const string route = $"/webAccounts?include={Unknown.Relationship}"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}", '^'); + string route = $"/webAccounts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -866,7 +867,7 @@ public async Task Cannot_include_unknown_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -875,7 +876,8 @@ public async Task Cannot_include_unknown_relationship() public async Task Cannot_include_unknown_nested_relationship() { // Arrange - const string route = $"/blogs?include=posts.{Unknown.Relationship}"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -888,7 +890,7 @@ public async Task Cannot_include_unknown_nested_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -897,7 +899,8 @@ public async Task Cannot_include_unknown_nested_relationship() public async Task Cannot_include_relationship_when_inclusion_blocked() { // Arrange - const string route = "/blogPosts?include=parent"; + var parameterValue = new MarkedText("^parent", '^'); + string route = $"/blogPosts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -909,8 +912,8 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' on 'blogPosts' is not allowed."); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -919,7 +922,8 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() public async Task Cannot_include_relationship_when_nested_inclusion_blocked() { // Arrange - const string route = "/blogs?include=posts.parent"; + var parameterValue = new MarkedText("posts.^parent", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -931,8 +935,8 @@ public async Task Cannot_include_relationship_when_nested_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' in 'posts.parent' on 'blogPosts' is not allowed."); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -1091,7 +1095,8 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - const string route = "/blogs/123/owner?include=posts.comments"; + var parameterValue = new MarkedText("^posts.comments", '^'); + string route = $"/blogs/123/owner?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -1104,7 +1109,7 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be("Including 'posts.comments' exceeds the maximum inclusion depth of 1."); + error.Detail.Should().Be($"Including 'posts.comments' exceeds the maximum inclusion depth of 1. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 9174c84058..ad6f8a1609 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -12,6 +12,7 @@ public sealed class PaginationWithTotalCountTests : IClassFixture, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -65,7 +66,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_primary_endpoint() + public async Task Cannot_paginate_in_primary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -89,7 +90,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[number]"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -162,7 +163,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_secondary_endpoint() + public async Task Cannot_paginate_in_secondary_resource() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -186,7 +187,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[size]"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -229,7 +230,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_paginate_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); @@ -263,7 +264,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); @@ -295,7 +296,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint_without_inverse_relationship() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint_without_inverse_relationship() { // Arrange WebAccount? account = _fakers.WebAccount.Generate(); @@ -366,7 +367,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_ManyToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_ManyToMany_relationship_at_relationship_endpoint() { // Arrange BlogPost post = _fakers.BlogPost.Generate(); @@ -451,7 +452,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_paginate_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?page[number]={Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -464,7 +466,7 @@ public async Task Cannot_paginate_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -473,7 +475,8 @@ public async Task Cannot_paginate_in_unknown_scope() public async Task Cannot_paginate_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?page[size]=posts.{Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -486,7 +489,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs index 57215e12a1..9af4e1201e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs @@ -170,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_at_secondary_endpoint() { // Arrange WebAccount account = _fakers.WebAccount.Generate(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index 6b715f0825..024e923313 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -30,7 +30,8 @@ public RangeValidationTests(IntegrationTestContext(route); @@ -43,7 +44,7 @@ public async Task Cannot_use_negative_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -52,7 +53,8 @@ public async Task Cannot_use_negative_page_number() public async Task Cannot_use_zero_page_number() { // Arrange - const string route = "/blogs?page[number]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -65,7 +67,7 @@ public async Task Cannot_use_zero_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -111,7 +113,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_use_negative_page_size() { // Arrange - const string route = "/blogs?page[size]=-1"; + var parameterValue = new MarkedText("^-1", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -124,7 +127,7 @@ public async Task Cannot_use_negative_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page size cannot be negative."); + error.Detail.Should().Be($"Page size cannot be negative. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index 66cb0dca57..5a8d375543 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -59,7 +59,8 @@ public async Task Cannot_use_page_number_over_maximum() { // Arrange const int pageNumber = MaximumPageNumber + 1; - string route = $"/blogs?page[number]={pageNumber}"; + var parameterValue = new MarkedText($"^{pageNumber}", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -72,7 +73,7 @@ public async Task Cannot_use_page_number_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); + error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -81,7 +82,8 @@ public async Task Cannot_use_page_number_over_maximum() public async Task Cannot_use_zero_page_size() { // Arrange - const string route = "/blogs?page[size]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -94,7 +96,7 @@ public async Task Cannot_use_zero_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be("Page size cannot be unconstrained."); + error.Detail.Should().Be($"Page size cannot be unconstrained. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -132,7 +134,8 @@ public async Task Cannot_use_page_size_over_maximum() { // Arrange const int pageSize = MaximumPageSize + 1; - string route = $"/blogs?page[size]={pageSize}"; + var parameterValue = new MarkedText($"^{pageSize}", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -145,7 +148,7 @@ public async Task Cannot_use_page_size_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); + error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}. {parameterValue}"); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 473a7428ba..3aa40b2725 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -21,6 +21,7 @@ public sealed class QueryStringDbContext : TestableDbContext public DbSet LoginAttempts => Set(); public DbSet Calendars => Set(); public DbSet Appointments => Set(); + public DbSet Reminders => Set(); public QueryStringDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index ab7fc4b77e..4afd53e405 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -31,7 +31,8 @@ internal sealed class QueryStringFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) .RuleFor(comment => comment.CreatedAt, faker => faker.Date.Past() - .TruncateToWholeMilliseconds())); + .TruncateToWholeMilliseconds()) + .RuleFor(comment => comment.NumStars, faker => faker.Random.Int(0, 10))); private readonly Lazy> _lazyWebAccountFaker = new(() => new Faker() @@ -54,6 +55,20 @@ internal sealed class QueryStringFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(accountPreferences => accountPreferences.UseDarkTheme, faker => faker.Random.Bool())); + private readonly Lazy> _lazyManFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(man => man.Name, faker => faker.Name.FullName()) + .RuleFor(man => man.HasBeard, faker => faker.Random.Bool()) + .RuleFor(man => man.Age, faker => faker.Random.Int(10, 90))); + + private readonly Lazy> _lazyWomanFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(woman => woman.Name, faker => faker.Name.FullName()) + .RuleFor(woman => woman.MaidenName, faker => faker.Name.LastName()) + .RuleFor(woman => woman.Age, faker => faker.Random.Int(10, 90))); + private readonly Lazy> _lazyCalendarFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) @@ -70,6 +85,12 @@ internal sealed class QueryStringFakers : FakerContainer .TruncateToWholeMilliseconds()) .RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4)))); + private readonly Lazy> _lazyReminderFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(reminder => reminder.RemindsAt, faker => faker.Date.Future() + .TruncateToWholeMilliseconds())); + public Faker Blog => _lazyBlogFaker.Value; public Faker BlogPost => _lazyBlogPostFaker.Value; public Faker