diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a88a657c3b..a573820074 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,8 +48,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.* - 9.0.* 10.0.* - name: Show installed versions shell: pwsh @@ -154,8 +152,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.* - 9.0.* 10.0.* - name: Git checkout uses: actions/checkout@v7 @@ -212,8 +208,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.* - 9.0.* 10.0.* - name: Git checkout uses: actions/checkout@v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e2035beb87..5ba4f27ea5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,8 +30,6 @@ jobs: uses: actions/setup-dotnet@v6 with: dotnet-version: | - 8.0.* - 9.0.* 10.0.* - name: Git checkout uses: actions/checkout@v7 diff --git a/Directory.Build.targets b/Directory.Build.targets index 94301c838a..ee56ca2661 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -14,4 +14,16 @@ + + + + + diff --git a/JsonApiDotNetCore.slnx.DotSettings b/JsonApiDotNetCore.slnx.DotSettings index 1016e6cda0..8933b09cbb 100644 --- a/JsonApiDotNetCore.slnx.DotSettings +++ b/JsonApiDotNetCore.slnx.DotSettings @@ -3,7 +3,6 @@ 2000 3000 False - 58FE19B6-F84A-990C-B25F-E37C391C3BD3/f:ReadOnlySet.cs SOLUTION True SUGGESTION @@ -600,6 +599,7 @@ True True True + True True Replace argument null check using throw expression with ArgumentNullException.ThrowIfNull True diff --git a/ROADMAP.md b/ROADMAP.md index 0e01efc277..5c3486d999 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ This document provides an overview of the direction this project is heading and We have an interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. -- Query strings on JSON-mapped columns [#1439](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1439) +- Compound attributes and collections (query strings, JSON columns, complex types) [#1439](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1439) (Implemented) - Optimistic concurrency [#1119](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119) - Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132) - Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776) diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index 2ab328c074..472f243a91 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -43,11 +43,11 @@ protected SerializationBenchmarkBase() var metaBuilder = new NoMetaBuilder(); IQueryConstraintProvider[] constraintProviders = []; var resourceDefinitionAccessor = new NeverResourceDefinitionAccessor(); - var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); + var sparseFieldSetEvaluator = new SparseFieldSetEvaluator(constraintProviders, resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); ResponseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, - sparseFieldSetCache, requestQueryStringAccessor); + sparseFieldSetEvaluator, requestQueryStringAccessor); } protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); diff --git a/docs/api/index.md b/docs/api/index.md index 962b1b4cab..c34af03743 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -84,7 +84,7 @@ Commonly used types are listed in the following sections. - implements - - -- implements +- implements ## Error handling diff --git a/docs/usage/faq.md b/docs/usage/faq.md index 7a8b2868b0..b4057f2ea1 100644 --- a/docs/usage/faq.md +++ b/docs/usage/faq.md @@ -133,7 +133,7 @@ Here are some injectable request-scoped types to be aware of: - `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates). - `IEnumerable`: Provides access to the parsed query string parameters. - `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render. -- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. +- `ISparseFieldSetEvaluator`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships). diff --git a/package-versions.props b/package-versions.props index 2fe57c295c..84c539f233 100644 --- a/package-versions.props +++ b/package-versions.props @@ -16,7 +16,6 @@ 2.0.* 0.10.* 4.1.* - 10.0.* 18.11.* 2.9.* 4.0.* @@ -28,24 +27,6 @@ 10.0.* - 10.0.*-* - - - - - N/A - - - 9.0.* - 9.0.* - - - - - 8.0.0 - - - 8.0.* - 8.0.* + 10.0.* diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj index 47e35d23ce..7ca897c154 100644 --- a/src/Examples/DapperExample/DapperExample.csproj +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -1,6 +1,6 @@  - net10.0;net9.0;net8.0 + net10.0 @@ -15,8 +15,7 @@ - + - diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs index 1c2549cf6a..a90db99254 100644 --- a/src/Examples/DapperExample/Program.cs +++ b/src/Examples/DapperExample/Program.cs @@ -30,11 +30,7 @@ } case DatabaseProvider.MySql: { -#if NET8_0 - ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString); -#else ServerVersion serverVersion = await ServerVersion.AutoDetectAsync(connectionString); -#endif builder.Services.AddMySql(connectionString, serverVersion, optionsAction: options => SetDbContextDebugOptions(options)); diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index c7cee804b1..9eb772dae3 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index 806fdf45a5..d3bf381fd3 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index c7cee804b1..9eb772dae3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index 806fdf45a5..d3bf381fd3 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index e50874f733..271865a75a 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs index 4a7205fee7..a47029cca1 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -85,6 +85,7 @@ private void LogFiltersInTopScope() FilterExpression[] filtersInTopScope = _constraintProviders .SelectMany(provider => provider.GetConstraints()) + .OfType() .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index 73a16ddf4f..60196fe1a5 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs new file mode 100644 index 0000000000..cce472103c --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -0,0 +1,102 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of a compound JSON:API attribute in the resource graph. +/// +[PublicAPI] +public sealed class CompoundType : IAttributeContainer +{ + private readonly Dictionary _attributesByPublicName = []; + private readonly Dictionary _attributesByPropertyName = []; + + /// + public Type ClrType { get; } + + /// + public IReadOnlyCollection Attributes { get; } + + public CompoundType(Type clrType, IReadOnlyCollection attributes) + { + ArgumentNullException.ThrowIfNull(clrType); + ArgumentNullException.ThrowIfNull(attributes); + + ClrType = clrType; + Attributes = attributes; + + foreach (AttrAttribute attribute in Attributes) + { + _attributesByPublicName.Add(attribute.PublicName, attribute); + _attributesByPropertyName.Add(attribute.Property.Name, attribute); + } + } + + /// + public AttrAttribute GetAttributeByPublicName(string publicName) + { + AttrAttribute? attribute = FindAttributeByPublicName(publicName); + return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on compound type '{ClrType.Name}'."); + } + + /// + public AttrAttribute? FindAttributeByPublicName(string publicName) + { + ArgumentNullException.ThrowIfNull(publicName); + + return _attributesByPublicName.GetValueOrDefault(publicName); + } + + /// + public AttrAttribute GetAttributeByPropertyName(string propertyName) + { + AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); + return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on compound type '{ClrType.Name}'."); + } + + /// + public AttrAttribute? FindAttributeByPropertyName(string propertyName) + { + ArgumentNullException.ThrowIfNull(propertyName); + + return _attributesByPropertyName.GetValueOrDefault(propertyName); + } + + /// + public override string ToString() + { + return ClrType.Name; + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (CompoundType)obj; + return ClrType == other.ClrType && Attributes.SequenceEqual(other.Attributes); + } + + /// + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(ClrType); + + foreach (AttrAttribute attribute in Attributes) + { + hashCode.Add(attribute); + } + + return hashCode.ToHashCode(); + } +} diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs new file mode 100644 index 0000000000..5f4e8f567d --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs @@ -0,0 +1,41 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of a type that contains JSON:API attributes. +/// +[PublicAPI] +public interface IAttributeContainer +{ + /// + /// The CLR type of the container. + /// + Type ClrType { get; } + + /// + /// The attributes in this container. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets the attribute for the specified publicly exposed name. Throws an when not found. + /// + AttrAttribute GetAttributeByPublicName(string publicName); + + /// + /// Attempts to find an attribute by its publicly exposed name. Returns null when not found. + /// + AttrAttribute? FindAttributeByPublicName(string publicName); + + /// + /// Gets the attribute for the specified property name. Throws an when not found. + /// + AttrAttribute GetAttributeByPropertyName(string propertyName); + + /// + /// Attempts to find an attribute by its property name. Returns null when not found. + /// + AttrAttribute? FindAttributeByPropertyName(string propertyName); +} diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 53ed2f882e..791b072dc0 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Configuration; /// Metadata about the shape of a JSON:API resource in the resource graph. /// [PublicAPI] -public sealed class ResourceType +public sealed class ResourceType : IAttributeContainer { private static readonly IReadOnlySet EmptyResourceTypeSet = new HashSet().AsReadOnly(); private static readonly IReadOnlySet EmptyAttributeSet = new HashSet().AsReadOnly(); @@ -157,12 +157,14 @@ private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet public AttrAttribute GetAttributeByPublicName(string publicName) { AttrAttribute? attribute = FindAttributeByPublicName(publicName); return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'."); } + /// public AttrAttribute? FindAttributeByPublicName(string publicName) { ArgumentNullException.ThrowIfNull(publicName); @@ -170,12 +172,14 @@ public AttrAttribute GetAttributeByPublicName(string publicName) return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; } + /// public AttrAttribute GetAttributeByPropertyName(string propertyName) { AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); } + /// public AttrAttribute? FindAttributeByPropertyName(string propertyName) { ArgumentNullException.ThrowIfNull(propertyName); diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index fd646bfd78..d52c37751e 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -1,6 +1,6 @@ - net10.0;net8.0;netstandard1.0 + net10.0;netstandard1.0 true true JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs b/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs deleted file mode 100644 index 9917af9ec7..0000000000 --- a/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs +++ /dev/null @@ -1,171 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if NET8_0 -#pragma warning disable - -// ReadOnlySet was introduced in .NET 9. -// This file was copied from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Collections/src/System/Collections/Generic/ReadOnlySet.cs -// and made internal to enable usage on lower .NET versions. - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; - -namespace System.Collections.ObjectModel; - -/// Represents a read-only, generic set of values. -/// The type of values in the set. -[DebuggerDisplay("Count = {Count}")] -[ExcludeFromCodeCoverage] -internal class ReadOnlySet : IReadOnlySet, ISet, ICollection -{ - /// The wrapped set. - private readonly ISet _set; - - /// Initializes a new instance of the class that is a wrapper around the specified set. - /// The set to wrap. - public ReadOnlySet(ISet set) - { - ArgumentNullException.ThrowIfNull(set); - _set = set; - } - - /// Gets an empty . - public static ReadOnlySet Empty { get; } = new ReadOnlySet(new HashSet()); - - /// Gets the set that is wrapped by this object. - protected ISet Set => _set; - - /// - public int Count => _set.Count; - - /// - public IEnumerator GetEnumerator() => - _set.Count == 0 ? ((IEnumerable)Array.Empty()).GetEnumerator() : - _set.GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - /// - public bool Contains(T item) => _set.Contains(item); - - /// - public bool IsProperSubsetOf(IEnumerable other) => _set.IsProperSubsetOf(other); - - /// - public bool IsProperSupersetOf(IEnumerable other) => _set.IsProperSupersetOf(other); - - /// - public bool IsSubsetOf(IEnumerable other) => _set.IsSubsetOf(other); - - /// - public bool IsSupersetOf(IEnumerable other) => _set.IsSupersetOf(other); - - /// - public bool Overlaps(IEnumerable other) => _set.Overlaps(other); - - /// - public bool SetEquals(IEnumerable other) => _set.SetEquals(other); - - /// - void ICollection.CopyTo(T[] array, int arrayIndex) => _set.CopyTo(array, arrayIndex); - - /// - void ICollection.CopyTo(Array array, int index) => CollectionHelpers.CopyTo(_set, array, index); - - /// - bool ICollection.IsReadOnly => true; - - /// - bool ICollection.IsSynchronized => false; - - /// - object ICollection.SyncRoot => _set is ICollection c ? c.SyncRoot : this; - - /// - bool ISet.Add(T item) => throw new NotSupportedException(); - - /// - void ISet.ExceptWith(IEnumerable other) => throw new NotSupportedException(); - - /// - void ISet.IntersectWith(IEnumerable other) => throw new NotSupportedException(); - - /// - void ISet.SymmetricExceptWith(IEnumerable other) => throw new NotSupportedException(); - - /// - void ISet.UnionWith(IEnumerable other) => throw new NotSupportedException(); - - /// - void ICollection.Add(T item) => throw new NotSupportedException(); - - /// - void ICollection.Clear() => throw new NotSupportedException(); - - /// - bool ICollection.Remove(T item) => throw new NotSupportedException(); - - private static class CollectionHelpers - { - private static void ValidateCopyToArguments(int sourceCount, Array array, int index) - { - ArgumentNullException.ThrowIfNull(array); - - if (array.Rank != 1) - { - throw new ArgumentException("Only single dimensional arrays are supported for the requested action.", nameof(array)); - } - - if (array.GetLowerBound(0) != 0) - { - throw new ArgumentException("The lower bound of target array must be zero.", nameof(array)); - } - - ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentOutOfRangeException.ThrowIfGreaterThan(index, array.Length); - - if (array.Length - index < sourceCount) - { - throw new ArgumentException("Destination array is not long enough to copy all the items in the collection. Check array index and length."); - } - } - - internal static void CopyTo(ICollection collection, Array array, int index) - { - ValidateCopyToArguments(collection.Count, array, index); - - if (collection is ICollection nonGenericCollection) - { - // Easy out if the ICollection implements the non-generic ICollection - nonGenericCollection.CopyTo(array, index); - } - else if (array is T[] items) - { - collection.CopyTo(items, index); - } - else - { - // We can't cast array of value type to object[], so we don't support widening of primitive types here. - if (array is not object?[] objects) - { - throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array)); - } - - try - { - foreach (T item in collection) - { - objects[index++] = item; - } - } - catch (ArrayTypeMismatchException) - { - throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array)); - } - } - } - } -} -#endif diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 26a660775a..30e8abe857 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -1,4 +1,7 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; + +// ReSharper disable NonReadonlyMemberInGetHashCode namespace JsonApiDotNetCore.Resources.Annotations; @@ -31,6 +34,21 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// + /// Indicates whether the underlying property is a collection. + /// + public bool IsCollection { get; internal set; } + + /// + /// Indicates whether this attribute represents a compound object. Defaults to false. + /// + public bool IsCompound { get; set; } + + /// + /// Provides access to nested attributes when is true; otherwise null. + /// + public CompoundType? CompoundType { get; internal set; } + /// public override bool Equals(object? obj) { @@ -46,12 +64,13 @@ public override bool Equals(object? obj) var other = (AttrAttribute)obj; - return Capabilities == other.Capabilities && base.Equals(other); + return Capabilities == other.Capabilities && IsCollection == other.IsCollection && IsCompound == other.IsCompound && + CompoundType == other.CompoundType && base.Equals(other); } /// public override int GetHashCode() { - return HashCode.Combine(Capabilities, base.GetHashCode()); + return HashCode.Combine(Capabilities, IsCollection, IsCompound, CompoundType, base.GetHashCode()); } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs index a7915240dc..0948dada0e 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs @@ -11,4 +11,7 @@ public sealed class AttrAttribute : ResourceFieldAttribute { /// public AttrCapabilities Capabilities { get; set; } + + /// + public bool IsCompound { get; set; } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index b580f0576f..c8679585da 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -41,9 +41,9 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public PropertyInfo? InverseNavigationProperty { get; set; } /// - /// The containing resource type in which this relationship is declared. Identical to . + /// The containing resource type in which this relationship is defined. Identical to . /// - public ResourceType LeftType => Type; + public ResourceType LeftType => (ResourceType)ContainingType; /// /// The resource type this relationship points to. In the case of a relationship, this value will be the collection diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index 6f54635827..ce970a73b3 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -16,7 +16,7 @@ public abstract class ResourceFieldAttribute : Attribute // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. private string? _publicName; private PropertyInfo? _property; - private ResourceType? _type; + private IAttributeContainer? _containingType; /// /// The publicly exposed name of this JSON:API field. When not explicitly set, the configured naming convention is applied on the property name. @@ -49,15 +49,15 @@ internal set } /// - /// The containing resource type in which this field is declared. + /// The container (resource type or compound type) in which this field is contained. /// - public ResourceType Type + public IAttributeContainer ContainingType { - get => _type!; + get => _containingType!; internal set { ArgumentNullException.ThrowIfNull(value); - _type = value; + _containingType = value; } } @@ -115,7 +115,8 @@ public virtual void SetValue(object resource, object? newValue) protected void AssertIsIdentifiable(object? resource) { - if (resource is not null and not IIdentifiable) + // Fields in a CompoundType represent unidentifiable embedded value objects, so skip IIdentifiable check for them. + if (_containingType is not CompoundType && resource is not null and not IIdentifiable) { #pragma warning disable CA1062 // Validate arguments of public methods throw new InvalidOperationException($"Resource of type '{resource.GetType()}' does not implement {nameof(IIdentifiable)}."); @@ -131,7 +132,14 @@ protected void AssertIsIdentifiable(object? resource) public string ToFullString() { - return $"{_type?.PublicName}:{ToString()}"; + string prefix = _containingType switch + { + ResourceType resourceType => resourceType.PublicName, + CompoundType compoundType => compoundType.ClrType.Name, + _ => string.Empty + }; + + return $"{prefix}:{ToString()}"; } /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6ae7d5f506..8e6097b96f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -18,12 +18,12 @@ public class OperationsProcessor : IOperationsProcessor private readonly IResourceGraph _resourceGraph; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; - private readonly ISparseFieldSetCache _sparseFieldSetCache; + private readonly ISparseFieldSetEvaluator _sparseFieldSetEvaluator; private readonly LocalIdValidator _localIdValidator; public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, - ISparseFieldSetCache sparseFieldSetCache) + ISparseFieldSetEvaluator sparseFieldSetEvaluator) { ArgumentNullException.ThrowIfNull(operationProcessorAccessor); ArgumentNullException.ThrowIfNull(operationsTransactionFactory); @@ -31,7 +31,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso ArgumentNullException.ThrowIfNull(resourceGraph); ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(targetedFields); - ArgumentNullException.ThrowIfNull(sparseFieldSetCache); + ArgumentNullException.ThrowIfNull(sparseFieldSetEvaluator); _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; @@ -39,7 +39,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; - _sparseFieldSetCache = sparseFieldSetCache; + _sparseFieldSetEvaluator = sparseFieldSetEvaluator; _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); } @@ -73,7 +73,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso await transaction.AfterProcessOperationAsync(cancellationToken); - _sparseFieldSetCache.Reset(); + _sparseFieldSetEvaluator.Reset(); } indexTracker.Reset(); diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index cbb37d5013..6168604223 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; @@ -59,7 +60,7 @@ public static IEnumerable ToEnumerable(this LinkedListNode? startNode) } } - public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + public static bool DictionaryEquals(this IImmutableDictionary? first, IImmutableDictionary? second, IEqualityComparer? valueComparer = null) { if (ReferenceEquals(first, second)) diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index a2c4f3283c..679d1b8f18 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -43,6 +44,37 @@ ResourceType GetResourceType() /// ResourceType? FindResourceType(Type resourceClrType); + /// + /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. + /// + ResourceType? FindResourceType() + where TResource : class, IIdentifiable; + + /// + /// Gets the metadata for all registered compound types. + /// + IReadOnlySet GetCompoundTypes(); + + /// + /// Gets the metadata for the compound type of the specified CLR type. Throws an when not found. + /// + CompoundType GetCompoundType(Type clrType); + + /// + /// Gets the metadata for the compound type of the specified CLR type. Throws an when not found. + /// + CompoundType GetCompoundType(); + + /// + /// Attempts to get metadata for the compound type of the specified CLR type. Returns null when not found. + /// + CompoundType? FindCompoundType(Type clrType); + + /// + /// Attempts to get metadata for the compound type of the specified CLR type. Returns null when not found. + /// + CompoundType? FindCompoundType(); + /// /// Gets the fields (attributes and relationships) for that are targeted by the selector. /// @@ -84,4 +116,35 @@ IReadOnlyCollection GetAttributes(Expression IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable; + + /// + /// Resolves the specified property chain selector into a . + /// + /// + /// The resource CLR type for which to resolve the field chain. + /// + /// + /// Should be of the form: resource.Field1.ChildField2 + /// ]]> + /// + ResourceFieldChainExpression GetFieldChain(Expression> selector) + where TResource : class, IIdentifiable; + + /// + /// Resolves the specified property chain selector into a collection of s. + /// + /// + /// The resource CLR type for which to resolve field chains. + /// + /// + /// Should be of the form: resource.Field1.ChildField2 + /// ]]> + /// or: new { resource.Field1, resource.Field2.ChildField3 } + /// ]]> + /// + IReadOnlyCollection GetFieldChains(Expression> selector) + where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index c20a91fe2e..2c3a4f83d1 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -173,7 +173,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.TryAddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.TryAddScoped(); _services.TryAddScoped(); - _services.TryAddScoped(); + _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddSingleton(); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 8d7a4a29e5..5ca1438090 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -31,20 +31,44 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt IServiceProvider serviceProvider = GetScopedServiceProvider(); var request = serviceProvider.GetRequiredService(); - bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); - if (!isTopResourceInPrimaryRequest) + if (!IsAtPrimaryEndpoint(request)) { return false; } - if (request.WriteOperation == WriteOperationKind.UpdateResource) + if (string.IsNullOrEmpty(parentEntry.Key)) { - var targetedFields = serviceProvider.GetRequiredService(); - return IsFieldTargeted(entry, targetedFields); + if (request.WriteOperation == WriteOperationKind.UpdateResource) + { + var targetedFields = serviceProvider.GetRequiredService(); + return IsFieldTargeted(entry, targetedFields); + } + + return true; + } + + var resourceGraph = serviceProvider.GetRequiredService(); + return IsInCompoundType(parentEntry, resourceGraph); + } + + private static bool IsInCompoundType(ValidationEntry parentEntry, IResourceGraph resourceGraph) + { + Type modelType = parentEntry.Metadata.ModelType; + + if (resourceGraph.FindCompoundType(modelType) != null) + { + return true; + } + + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(modelType); + + if (elementType != null && resourceGraph.FindCompoundType(elementType) != null) + { + return true; } - return true; + return false; } private IServiceProvider GetScopedServiceProvider() diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index a4ffd082d3..f61bc028e4 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -1,7 +1,9 @@ +using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,21 +15,32 @@ public sealed class ResourceGraph : IResourceGraph { private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + private static readonly IReadOnlySet EmptyCompoundTypeSet = new HashSet().AsReadOnly(); + private readonly IReadOnlySet _resourceTypeSet; private readonly Dictionary _resourceTypesByClrType = []; private readonly Dictionary _resourceTypesByPublicName = []; + private readonly IReadOnlySet _compoundTypeSet; + private readonly Dictionary _compoundTypesByClrType = []; - public ResourceGraph(IReadOnlySet resourceTypeSet) + public ResourceGraph(IReadOnlySet resourceTypeSet, IReadOnlySet compoundTypeSet) { ArgumentNullException.ThrowIfNull(resourceTypeSet); + ArgumentNullException.ThrowIfNull(compoundTypeSet); _resourceTypeSet = resourceTypeSet; + _compoundTypeSet = compoundTypeSet; foreach (ResourceType resourceType in resourceTypeSet) { _resourceTypesByClrType.Add(resourceType.ClrType, resourceType); _resourceTypesByPublicName.Add(resourceType.PublicName, resourceType); } + + foreach (CompoundType compoundType in compoundTypeSet) + { + _compoundTypesByClrType.Add(compoundType.ClrType, compoundType); + } } /// @@ -79,6 +92,45 @@ public ResourceType GetResourceType(Type resourceClrType) return _resourceTypesByClrType.GetValueOrDefault(typeToFind); } + /// + public IReadOnlySet GetCompoundTypes() + { + return _compoundTypeSet; + } + + /// + public CompoundType GetCompoundType(Type clrType) + { + CompoundType? compoundType = FindCompoundType(clrType); + + if (compoundType == null) + { + throw new InvalidOperationException($"Compound type '{clrType}' does not exist in the resource graph."); + } + + return compoundType; + } + + /// + public CompoundType GetCompoundType() + { + return GetCompoundType(typeof(TCompound)); + } + + /// + public CompoundType? FindCompoundType(Type clrType) + { + ArgumentNullException.ThrowIfNull(clrType); + + return _compoundTypesByClrType.GetValueOrDefault(clrType); + } + + /// + public CompoundType? FindCompoundType() + { + return FindCompoundType(typeof(TCompound)); + } + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) { return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; @@ -91,6 +143,13 @@ public ResourceType GetResourceType() return GetResourceType(typeof(TResource)); } + /// + public ResourceType? FindResourceType() + where TResource : class, IIdentifiable + { + return FindResourceType(typeof(TResource)); + } + /// public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable @@ -118,6 +177,161 @@ public IReadOnlyCollection GetRelationships(Ex return FilterFields(selector); } + /// + public ResourceFieldChainExpression GetFieldChain(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(selector); + + IReadOnlyCollection chains = GetFieldChains(selector); + + if (chains.Count != 1) + { + throw new ArgumentException($"The expression '{selector}' must select a single property or chain.", nameof(selector)); + } + + return chains.First(); + } + + /// + public IReadOnlyCollection GetFieldChains(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(selector); + + ResourceType resourceType = GetResourceType(); + return ResolveFieldChains(selector, resourceType); + } + + private ReadOnlyCollection ResolveFieldChains(Expression> selector, + ResourceType resourceType) + { + Expression selectorBody = RemoveConvert(selector.Body); + ParameterExpression parameter = selector.Parameters[0]; + + List> memberChains = []; + + if (selectorBody is MemberExpression memberExpression) + { + memberChains.Add(ExtractMemberChain(memberExpression, parameter, selector)); + } + else if (selectorBody is NewExpression newExpression) + { + foreach (Expression argument in newExpression.Arguments) + { + Expression argumentBody = RemoveConvert(argument); + + if (argumentBody is not MemberExpression argMemberExpr) + { + throw new ArgumentException($"The expression '{selector}' contains an invalid member selection. Expected member access expression.", + nameof(selector)); + } + + memberChains.Add(ExtractMemberChain(argMemberExpr, parameter, selector)); + } + } + else + { + throw new ArgumentException( + $"The expression '{selector}' should select a single property or chain, or select multiple properties into an anonymous type. " + + "For example: 'article => article.Title' or 'article => article.Details.Nested.Description' or 'article => new { article.Title, article.Details.Nested.Description }'.", + nameof(selector)); + } + + List fieldChains = []; + + foreach (List memberChain in memberChains) + { + fieldChains.Add(ResolveSingleFieldChain(memberChain, resourceType, selector)); + } + + return fieldChains.AsReadOnly(); + } + + private static List ExtractMemberChain(MemberExpression memberExpression, ParameterExpression parameter, Expression selector) + { + List members = []; + Expression? current = memberExpression; + + while (current != null) + { + current = RemoveConvert(current); + + if (current == parameter) + { + members.Reverse(); + return members; + } + + if (current is MemberExpression memberExpr) + { + members.Add(memberExpr.Member); + current = memberExpr.Expression; + } + else + { + throw new ArgumentException( + $"The expression '{selector}' contains an invalid expression '{current}'. Expected property member access on '{parameter.Name}'.", + nameof(selector)); + } + } + + throw new ArgumentException($"The expression '{selector}' does not originate from parameter '{parameter.Name}'.", nameof(selector)); + } + + private static ResourceFieldChainExpression ResolveSingleFieldChain(List members, ResourceType resourceType, Expression selector) + { + List fields = []; + IAttributeContainer currentContainer = resourceType; + + for (int index = 0; index < members.Count; index++) + { + MemberInfo member = members[index]; + ResourceFieldAttribute? field = null; + + if (currentContainer is ResourceType currentResourceType) + { + field = currentResourceType.FindAttributeByPropertyName(member.Name) ?? + (ResourceFieldAttribute?)currentResourceType.FindRelationshipByPropertyName(member.Name); + } + else if (currentContainer is CompoundType compoundType) + { + field = compoundType.FindAttributeByPropertyName(member.Name); + } + + if (field == null) + { + string containerName = currentContainer is ResourceType resType ? resType.PublicName : currentContainer.ClrType.Name; + + throw new ArgumentException( + $"The expression '{selector}' contains member '{member.Name}', which is not exposed as a JSON:API field on '{containerName}'.", + nameof(selector)); + } + + fields.Add(field); + + if (index < members.Count - 1) + { + if (field is AttrAttribute { IsCompound: true, CompoundType: not null } compoundAttr) + { + currentContainer = compoundAttr.CompoundType; + } + else if (field is RelationshipAttribute relationshipAttr) + { + currentContainer = relationshipAttr.RightType; + } + else + { + throw new ArgumentException( + $"The expression '{selector}' contains member '{field.Property.Name}', which cannot have nested fields because it is neither a compound attribute nor a relationship.", + nameof(selector)); + } + } + } + + return new ResourceFieldChainExpression(fields.ToImmutableArray()); + } + private ReadOnlyCollection FilterFields(Expression> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 5f020cb4d9..caa384a548 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -14,11 +14,12 @@ namespace JsonApiDotNetCore.Configuration; /// Builds and configures the . /// [PublicAPI] -public partial class ResourceGraphBuilder +public sealed partial class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; private readonly Dictionary _resourceTypesByClrType = []; + private readonly Dictionary _compoundTypesByClrType = []; private readonly HashSet _nonIdentifiableTypes = []; private readonly TypeLocator _typeLocator = new(); @@ -36,21 +37,25 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { + DiscoverCompoundTypes(); + AssertNoCyclesInCompoundTypes(); + foreach (Type nonIdentifiableType in _nonIdentifiableTypes) { LogResourceTypeDoesNotImplementIdentifiable(nonIdentifiableType, nameof(IIdentifiable), nameof(SuppressWarningNotIdentifiable)); } IReadOnlySet resourceTypes = _resourceTypesByClrType.Values.ToHashSet().AsReadOnly(); + IReadOnlySet compoundTypes = _compoundTypesByClrType.Values.ToHashSet().AsReadOnly(); if (resourceTypes.Count == 0) { LogResourceGraphIsEmpty(); } - var resourceGraph = new ResourceGraph(resourceTypes); + var resourceGraph = new ResourceGraph(resourceTypes, compoundTypes); - SetFieldTypes(resourceGraph); + SetFieldContainingTypes(resourceGraph); SetRelationshipTypes(resourceGraph); SetDirectlyDerivedTypes(resourceGraph); ValidateFieldsInDerivedTypes(resourceGraph); @@ -58,11 +63,22 @@ public IResourceGraph Build() return resourceGraph; } - private static void SetFieldTypes(ResourceGraph resourceGraph) + private static void SetFieldContainingTypes(ResourceGraph resourceGraph) { - foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields)) + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + foreach (ResourceFieldAttribute field in resourceType.Fields) + { + field.ContainingType = resourceType; + } + } + + foreach (CompoundType compoundType in resourceGraph.GetCompoundTypes()) { - field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!); + foreach (AttrAttribute attribute in compoundType.Attributes) + { + attribute.ContainingType = compoundType; + } } } @@ -70,9 +86,15 @@ private static void SetRelationshipTypes(ResourceGraph resourceGraph) { foreach (RelationshipAttribute relationship in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) { - Type rightClrType = relationship is HasOneAttribute + Type? rightClrType = relationship is HasOneAttribute ? relationship.Property.PropertyType - : relationship.Property.PropertyType.GetGenericArguments()[0]; + : CollectionConverter.Instance.FindCollectionElementType(relationship.Property.PropertyType); + + if (rightClrType == null) + { + throw new InvalidConfigurationException( + $"To-many relationship '{relationship.LeftType.ClrType}.{relationship.Property.Name}' must use a collection type."); + } ResourceType? rightType = resourceGraph.FindResourceType(rightClrType); @@ -331,11 +353,19 @@ private Dictionary.ValueCollection GetAttributes(Type res SetPublicName(attribute, property); attribute.Property = property; + attribute.IsCollection = property.PropertyType != typeof(string) && + CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null; + if (!attribute.HasExplicitCapabilities) { attribute.Capabilities = _options.DefaultAttrCapabilities; } + if (attribute.IsCompound) + { + attribute.Capabilities &= ~AttrCapabilities.AllowSort; + } + IncludeField(attributesByName, attribute); } @@ -347,6 +377,160 @@ private Dictionary.ValueCollection GetAttributes(Type res return attributesByName.Values; } + private void DiscoverCompoundTypes() + { + var pendingAttributes = new Queue(); + + foreach (ResourceType resourceType in _resourceTypesByClrType.Values) + { + foreach (AttrAttribute attribute in resourceType.Attributes) + { + if (attribute.IsCompound) + { + pendingAttributes.Enqueue(attribute); + } + } + } + + while (pendingAttributes.Count > 0) + { + AttrAttribute compoundAttribute = pendingAttributes.Dequeue(); + CompoundType compoundType = GetOrCreateCompoundType(compoundAttribute, pendingAttributes); + compoundAttribute.CompoundType = compoundType; + } + } + + private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Queue pendingAttributes) + { + Type compoundClrType = GetCompoundClrType(compoundAttribute.Property, compoundAttribute.IsCollection); + + if (compoundClrType.IsOrImplementsInterface()) + { + throw new InvalidConfigurationException( + $"Property '{compoundAttribute.Property.DeclaringType!.Name}.{compoundAttribute.Property.Name}' implements IIdentifiable and must be exposed as a JSON:API relationship using [HasOne] or [HasMany], not as a compound attribute."); + } + + compoundAttribute.Capabilities &= ~AttrCapabilities.AllowSort; + + if (_resourceTypesByClrType.ContainsKey(compoundClrType)) + { + throw new InvalidConfigurationException($"Type '{compoundClrType}' cannot be both a resource type and a compound type."); + } + + _nonIdentifiableTypes.Remove(compoundClrType); + + if (_compoundTypesByClrType.TryGetValue(compoundClrType, out CompoundType? existingCompoundType)) + { + return existingCompoundType; + } + + if (compoundClrType.GetCustomAttribute(true) != null) + { + throw new InvalidConfigurationException($"Type '{compoundClrType}' is a compound type and cannot be annotated with [Resource]."); + } + + var attributesByName = new Dictionary(); + + foreach (PropertyInfo property in compoundClrType.GetProperties()) + { + var relationship = property.GetCustomAttribute(true); + + if (relationship != null) + { + throw new InvalidConfigurationException( + $"Property '{compoundClrType.Name}.{property.Name}' in compound type '{compoundClrType.Name}' cannot be annotated with [{relationship.GetType().Name}]. Compound types cannot contain relationships."); + } + + var eagerLoad = property.GetCustomAttribute(true); + + if (eagerLoad != null) + { + throw new InvalidConfigurationException( + $"Property '{compoundClrType.Name}.{property.Name}' in compound type '{compoundClrType.Name}' cannot be annotated with [EagerLoad]."); + } + + var attribute = property.GetCustomAttribute(true); + + if (attribute != null) + { + SetPublicName(attribute, property); + attribute.Property = property; + + attribute.IsCollection = property.PropertyType != typeof(string) && + CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null; + + if (!attribute.HasExplicitCapabilities) + { + attribute.Capabilities = _options.DefaultAttrCapabilities; + } + + if (attribute.IsCompound) + { + attribute.Capabilities &= ~AttrCapabilities.AllowSort; + pendingAttributes.Enqueue(attribute); + } + + IncludeField(attributesByName, attribute); + } + } + + var newCompoundType = new CompoundType(compoundClrType, attributesByName.Values); + + foreach (AttrAttribute attribute in newCompoundType.Attributes) + { + attribute.ContainingType = newCompoundType; + } + + _compoundTypesByClrType.Add(compoundClrType, newCompoundType); + return newCompoundType; + } + + private void AssertNoCyclesInCompoundTypes() + { + HashSet visited = []; + HashSet inPath = []; + + foreach (Type compoundType in _compoundTypesByClrType.Keys) + { + AssertNoCyclesInCompoundTypes(compoundType, visited, inPath); + } + } + + private void AssertNoCyclesInCompoundTypes(Type currentType, HashSet visited, HashSet inPath) + { + if (inPath.Contains(currentType)) + { + throw new InvalidConfigurationException($"Circular reference detected: compound type '{currentType}' references itself directly or indirectly."); + } + + if (!visited.Add(currentType)) + { + return; + } + + inPath.Add(currentType); + + CompoundType compoundType = _compoundTypesByClrType[currentType]; + + foreach (AttrAttribute attribute in compoundType.Attributes) + { + if (attribute is { IsCompound: true, CompoundType: not null }) + { + AssertNoCyclesInCompoundTypes(attribute.CompoundType.ClrType, visited, inPath); + } + } + + inPath.Remove(currentType); + } + + private static Type GetCompoundClrType(PropertyInfo property, bool isCollection) + { + Type propertyType = property.PropertyType; + Type? elementType = isCollection ? CollectionConverter.Instance.FindCollectionElementType(propertyType) : null; + Type targetType = elementType ?? Nullable.GetUnderlyingType(propertyType) ?? propertyType; + return targetType; + } + private Dictionary.ValueCollection GetRelationships(Type resourceClrType) { var relationshipsByName = new Dictionary(); diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 48655941d4..ef95f58bd7 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -32,7 +32,7 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac { ArgumentNullException.ThrowIfNull(services); - SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? []); return services; } diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 0f82f6ffea..eea472907f 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -1,6 +1,5 @@ using System.Net; using System.Reflection; -using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -94,17 +93,33 @@ private static List FromModelStateDictionary(IReadOnlyDictionary(true); - return jsonNameAttribute?.Name ?? property.Name; - } } } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index c1a6e96ff2..994654b61a 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - net10.0;net8.0 + net10.0 true true @@ -39,12 +39,6 @@ - - - diff --git a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs index 72a311d246..ce995fd1b3 100644 --- a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs @@ -38,6 +38,7 @@ public void Set(IncludeExpression include) _include = _constraintProviders .SelectMany(provider => provider.GetConstraints()) + .OfType() .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index 4cab3e203b..1e718b8599 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -1,28 +1,21 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Queries; /// -/// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. +/// The expression that represents a parsed query string parameter, along with the scope to which it applies. /// -[PublicAPI] -public class ExpressionInScope +public abstract class ExpressionInScope { - public ResourceFieldChainExpression? Scope { get; } public QueryExpression Expression { get; } - public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) + /// + /// This constructor prevents external inheritance. + /// + private protected ExpressionInScope(QueryExpression expression) { ArgumentNullException.ThrowIfNull(expression); - Scope = scope; Expression = expression; } - - public override string ToString() - { - return $"{Scope} => {Expression}"; - } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index bc6982af5a..f06470ccea 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -32,6 +32,13 @@ private IncludeExpression() Elements = ImmutableHashSet.Empty; } + internal static IncludeExpression Create(IImmutableSet elements) + { + ArgumentNullException.ThrowIfNull(elements); + + return elements.Count > 0 ? new IncludeExpression(elements) : Empty; + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitInclude(this, argument); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index dac7493109..3a4031c176 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -8,7 +8,28 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class QueryExpression { + /// + /// Dispatches the current query expression to the specified visitor with the given argument. + /// + /// + /// The type of the argument passed to the visitor. + /// + /// + /// The type of the result returned by the visitor. + /// + /// + /// A visitor that processes the query expression. + /// + /// + /// An argument to pass to the visitor. + /// + /// + /// The result produced by the visitor. + /// public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); + /// + /// Returns a string representation that includes all details of the expression. + /// public abstract string ToFullString(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 2da39bb2fc..b3f314d288 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; #pragma warning disable IDE0019 // Use pattern matching @@ -51,6 +51,11 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } + public override QueryExpression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, TArgument argument) + { + return expression; + } + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) { IImmutableList newTerms = VisitList(expression.Terms, argument); @@ -175,31 +180,20 @@ public override QueryExpression VisitPagination(PaginationExpression expression, return null; } - public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) { - ImmutableDictionary.Builder newTable = - ImmutableDictionary.CreateBuilder(); + IImmutableDictionary newElements = VisitDictionary(expression.Elements, argument); - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) - { - if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) - { - newTable[resourceType] = newSparseFieldSet; - } - } - - if (newTable.Count > 0) - { - var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = SparseFieldSetExpression.Create(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + public override QueryExpression VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, TArgument argument) { - return expression; + IImmutableDictionary newChildren = VisitDictionary(expression.Children, argument); + + var newExpression = new SparseFieldSetElementExpression(expression.Field, newChildren); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) @@ -236,12 +230,7 @@ public override QueryExpression VisitInclude(IncludeExpression expression, TArgu { IImmutableSet newElements = VisitSet(expression.Elements, argument); - if (newElements.Count == 0) - { - return IncludeExpression.Empty; - } - - var newExpression = new IncludeExpression(newElements); + var newExpression = IncludeExpression.Create(newElements); return newExpression.Equals(expression) ? expression : newExpression; } @@ -289,4 +278,22 @@ protected virtual IImmutableSet VisitSet(IImmutableSet return setBuilder.ToImmutable(); } + + protected virtual IImmutableDictionary VisitDictionary(IImmutableDictionary elements, + TArgument argument) + where TKey : notnull + where TExpression : QueryExpression + { + ImmutableDictionary.Builder builder = ImmutableDictionary.CreateBuilder(); + + foreach ((TKey key, TExpression value) in elements) + { + if (Visit(value, argument) is TExpression newValue) + { + builder[key] = newValue; + } + } + + return builder.ToImmutable(); + } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index a0472306f7..1c848a7288 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -44,6 +44,11 @@ public virtual TResult VisitNullConstant(NullConstantExpression expression, TArg return DefaultVisit(expression, argument); } + public virtual TResult VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument) { return DefaultVisit(expression, argument); @@ -94,12 +99,12 @@ public virtual TResult VisitAny(AnyExpression expression, TArgument argument) return DefaultVisit(expression, argument); } - public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) { return DefaultVisit(expression, argument); } - public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + public virtual TResult VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, TArgument argument) { return DefaultVisit(expression, argument); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs new file mode 100644 index 0000000000..b1e018c1d4 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs @@ -0,0 +1,70 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the element value of a scalar collection (_), resulting from text such as: +/// +/// has(tags,equals(_,'Work')) +/// +/// . +/// +[PublicAPI] +public sealed class ScalarCollectionElementValueExpression : IdentifierExpression +{ + private const string Symbol = "_"; + + /// + /// The CLR type of the scalar collection element, such as or . + /// + public Type ElementType { get; } + + public ScalarCollectionElementValueExpression(Type elementType) + { + ArgumentNullException.ThrowIfNull(elementType); + + ElementType = elementType; + } + + /// + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitScalarCollectionElementValue(this, argument); + } + + /// + public override string ToString() + { + return Symbol; + } + + /// + 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 = (ScalarCollectionElementValueExpression)obj; + + return ElementType == other.ElementType; + } + + /// + public override int GetHashCode() + { + return ElementType.GetHashCode(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs new file mode 100644 index 0000000000..ef01e27903 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs @@ -0,0 +1,120 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in a tree, resulting from text such as: +/// +/// contact.name.firstName,contact.name.lastName +/// +/// . +/// +[PublicAPI] +public class SparseFieldSetElementExpression : QueryExpression +{ + private static readonly ImmutableDictionary EmptyChildren = + ImmutableDictionary.Empty; + + /// + /// The JSON:API field (attribute or relationship) to include. + /// + public ResourceFieldAttribute Field { get; } + + /// + /// The direct children of this subtree for compound attributes, keyed by child field. An empty dictionary indicates that all viewable child attributes + /// are included. + /// + public IImmutableDictionary Children { get; } + + public SparseFieldSetElementExpression(ResourceFieldAttribute field) + : this(field, EmptyChildren) + { + } + + public SparseFieldSetElementExpression(ResourceFieldAttribute field, IImmutableDictionary children) + { + ArgumentNullException.ThrowIfNull(field); + ArgumentNullException.ThrowIfNull(children); + + Field = field; + Children = children; + } + + /// + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldSetElement(this, argument); + } + + /// + public override string ToString() + { + return InnerToString(false); + } + + /// + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + return string.Join(',', Flatten(this, toFullString).Order()); + } + + internal static IEnumerable Flatten(SparseFieldSetElementExpression element, bool toFullString) + { + string fieldText = toFullString ? element.Field.ToFullString() : element.Field.ToString()!; + + if (element.Children.Count == 0) + { + yield return fieldText; + } + else + { + foreach (SparseFieldSetElementExpression child in element.Children.Values) + { + foreach (string childText in Flatten(child, toFullString)) + { + yield return $"{fieldText}.{childText}"; + } + } + } + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (SparseFieldSetElementExpression)obj; + + return Field.Equals(other.Field) && Children.DictionaryEquals(other.Children); + } + + /// + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Field); + + foreach ((ResourceFieldAttribute childField, SparseFieldSetElementExpression childElement) in Children.OrderBy(pair => pair.Key.PublicName)) + { + hashCode.Add(childField); + hashCode.Add(childElement); + } + + return hashCode.ToHashCode(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 7a4495bd83..9ce237ec09 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -9,38 +9,68 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// firstName,lastName,articles /// +/// , or: +/// +/// contact.name.firstName,contact.address +/// /// . /// [PublicAPI] public class SparseFieldSetExpression : QueryExpression { /// - /// The set of JSON:API fields to include. Chain format: a single field. + /// Indicates not to include any fields. + /// + public static readonly SparseFieldSetExpression Empty = new(); + + /// + /// The JSON:API fields included in this sparse fieldset tree, keyed by field (attribute or relationship). /// - public IImmutableSet Fields { get; } + public IImmutableDictionary Elements { get; } - public SparseFieldSetExpression(IImmutableSet fields) + public SparseFieldSetExpression(IImmutableDictionary elements) { - ArgumentGuard.NotNullNorEmpty(fields); + ArgumentGuard.NotNullNorEmpty(elements); + + Elements = elements; + } - Fields = fields; + private SparseFieldSetExpression() + { + Elements = ImmutableDictionary.Empty; } + internal static SparseFieldSetExpression Create(IImmutableDictionary elements) + { + ArgumentNullException.ThrowIfNull(elements); + + return elements.Count > 0 ? new SparseFieldSetExpression(elements) : Empty; + } + + /// public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitSparseFieldSet(this, argument); } + /// public override string ToString() { - return string.Join(',', Fields.Select(field => field.ToString()).Order()); + return InnerToString(false); } + /// public override string ToFullString() { - return string.Join(',', Fields.Select(field => $"{field.ToFullString()}").Order()); + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + return string.Join(',', Elements.Values.SelectMany(element => SparseFieldSetElementExpression.Flatten(element, toFullString)).Order()); } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -55,16 +85,18 @@ public override bool Equals(object? obj) var other = (SparseFieldSetExpression)obj; - return Fields.SetEquals(other.Fields); + return Elements.DictionaryEquals(other.Elements); } + /// public override int GetHashCode() { var hashCode = new HashCode(); - foreach (ResourceFieldAttribute field in Fields) + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in Elements.OrderBy(pair => pair.Key.PublicName)) { hashCode.Add(field); + hashCode.Add(element); } return hashCode.ToHashCode(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 936071ffd1..9db7d9c8ff 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -17,25 +16,47 @@ public static class SparseFieldSetExpressionExtensions ArgumentNullException.ThrowIfNull(fieldSelector); ArgumentNullException.ThrowIfNull(resourceGraph); - SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; + if (sparseFieldSet == null) + { + return null; + } + + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + + foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) + { + builder.IncludeChain(chain.Fields); + } + + return builder.ToExpression(); + } + + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field) + { + ArgumentNullException.ThrowIfNull(field); - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + if (sparseFieldSet == null) { - newSparseFieldSet = IncludeField(newSparseFieldSet, field); + return null; } - return newSparseFieldSet; + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.IncludeChain([field]); + return builder.ToExpression(); } - private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) { - if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) + ArgumentNullException.ThrowIfNull(fieldChain); + + if (sparseFieldSet == null) { - return sparseFieldSet; + return null; } - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude); - return new SparseFieldSetExpression(newSparseFieldSet); + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.IncludeChain(fieldChain.Fields); + return builder.ToExpression(); } public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, @@ -45,29 +66,50 @@ public static class SparseFieldSetExpressionExtensions ArgumentNullException.ThrowIfNull(fieldSelector); ArgumentNullException.ThrowIfNull(resourceGraph); - SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; + // Design tradeoff: When the sparse fieldset is null, it means all fields will be selected. + // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. + // But later, when serializing the response, the sparse fieldset is first populated with all fields, + // so then the exclusion will actually be applied and the excluded field is not returned to the client. + if (sparseFieldSet == null) + { + return null; + } - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + + foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) { - newSparseFieldSet = ExcludeField(newSparseFieldSet, field); + builder.ExcludeChain(chain.Fields); } - return newSparseFieldSet; + return builder.ToExpression(); } - private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field) { - // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. - // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. - // But later, when serializing the response, the sparse fieldset is first populated with all fields, - // so then the exclusion will actually be applied and the excluded field is not returned to the client. + ArgumentNullException.ThrowIfNull(field); + + if (sparseFieldSet == null) + { + return null; + } + + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.ExcludeChain([field]); + return builder.ToExpression(); + } + + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) + { + ArgumentNullException.ThrowIfNull(fieldChain); - if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) + if (sparseFieldSet == null) { - return sparseFieldSet; + return null; } - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude); - return new SparseFieldSetExpression(newSparseFieldSet); + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.ExcludeChain(fieldChain.Fields); + return builder.ToExpression(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs deleted file mode 100644 index 0c84ae956e..0000000000 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCore.Queries.Expressions; - -/// -/// Represents a lookup table of sparse fieldsets per resource type. -/// -[PublicAPI] -public class SparseFieldTableExpression : QueryExpression -{ - /// - /// The set of JSON:API fields to include, per resource type. - /// - public IImmutableDictionary Table { get; } - - public SparseFieldTableExpression(IImmutableDictionary table) - { - ArgumentGuard.NotNullNorEmpty(table); - - Table = table; - } - - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSparseFieldTable(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(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression fieldSet) in Table) - { - if (builder.Length > 0) - { - builder.Append(','); - } - - builder.Append(resourceType); - builder.Append('('); - builder.Append(toFullString ? fieldSet.ToFullString() : fieldSet.ToString()); - 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 = (SparseFieldTableExpression)obj; - - return Table.DictionaryEqual(other.Table); - } - - public override int GetHashCode() - { - var hashCode = new HashCode(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table) - { - hashCode.Add(resourceType); - hashCode.Add(sparseFieldSet); - } - - return hashCode.ToHashCode(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs new file mode 100644 index 0000000000..5a19ae5dad --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs @@ -0,0 +1,168 @@ +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions; + +internal sealed class SparseFieldTreeBuilder +{ + private readonly SparseFieldTreeNode _root = new(null); + + public static SparseFieldTreeBuilder FromExpression(SparseFieldSetExpression sparseFieldSet) + { + var builder = new SparseFieldTreeBuilder(); + + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in sparseFieldSet.Elements) + { + builder._root.AddChild(field, SparseFieldTreeNode.FromElement(element)); + } + + return builder; + } + + public void IncludeChain(IReadOnlyList chain) + { + if (chain.Count > 0) + { + _root.IncludeSubChain(chain, 0); + } + } + + public void ExcludeChain(IReadOnlyList chain) + { + if (chain.Count > 0) + { + _root.ExcludeSubChain(chain, 0); + } + } + + public SparseFieldSetExpression ToExpression() + { + return SparseFieldSetExpression.Create(_root.ToChildrenDictionary()); + } + + private sealed class SparseFieldTreeNode(ResourceFieldAttribute? field) + { + private readonly Dictionary _children = []; + + private readonly ResourceFieldAttribute? _field = field; + private bool _selectAllChildren; + + private bool IsEmpty => !_selectAllChildren && _children.Count == 0 && _field is AttrAttribute { IsCompound: true }; + + public void AddChild(ResourceFieldAttribute fieldToAdd, SparseFieldTreeNode node) + { + _children[fieldToAdd] = node; + } + + public static SparseFieldTreeNode FromElement(SparseFieldSetElementExpression element) + { + var node = new SparseFieldTreeNode(element.Field) + { + _selectAllChildren = element.Children.Count == 0 && element.Field is AttrAttribute { IsCompound: true } + }; + + foreach ((ResourceFieldAttribute childField, SparseFieldSetElementExpression child) in element.Children) + { + node._children[childField] = FromElement(child); + } + + return node; + } + + public void IncludeSubChain(IReadOnlyList chain, int index) + { + if (index >= chain.Count) + { + // This node is the leaf of the included chain. + _selectAllChildren = _field is AttrAttribute { IsCompound: true }; + _children.Clear(); + return; + } + + if (_selectAllChildren) + { + // Parent already selects all child fields, so deeper path is redundant. + return; + } + + ResourceFieldAttribute childField = chain[index]; + + if (!_children.TryGetValue(childField, out SparseFieldTreeNode? childNode)) + { + childNode = new SparseFieldTreeNode(childField) + { + _selectAllChildren = index == chain.Count - 1 && childField is AttrAttribute { IsCompound: true } + }; + + _children[childField] = childNode; + } + + childNode.IncludeSubChain(chain, index + 1); + } + + public void ExcludeSubChain(IReadOnlyList chain, int index) + { + if (_selectAllChildren && _field is AttrAttribute { CompoundType: not null } compoundAttr) + { + ExpandAllChildren(compoundAttr.CompoundType); + } + + ResourceFieldAttribute childField = chain[index]; + + if (!_children.TryGetValue(childField, out SparseFieldTreeNode? childNode)) + { + return; + } + + if (index == chain.Count - 1) + { + _children.Remove(childField); + return; + } + + childNode.ExcludeSubChain(chain, index + 1); + + if (childNode.IsEmpty) + { + _children.Remove(childField); + } + } + + private void ExpandAllChildren(CompoundType compoundType) + { + _selectAllChildren = false; + + foreach (AttrAttribute attr in compoundType.Attributes) + { + if (!attr.IsViewBlocked() && !_children.ContainsKey(attr)) + { + _children[attr] = new SparseFieldTreeNode(attr) + { + _selectAllChildren = attr.IsCompound + }; + } + } + } + + public ImmutableDictionary ToChildrenDictionary() + { + ImmutableDictionary.Builder builder = + ImmutableDictionary.CreateBuilder(); + + foreach ((ResourceFieldAttribute childField, SparseFieldTreeNode childNode) in _children) + { + builder.Add(childField, childNode.ToElement()); + } + + return builder.ToImmutable(); + } + + private SparseFieldSetElementExpression ToElement() + { + return _selectAllChildren || _children.Count == 0 + ? new SparseFieldSetElementExpression(_field!) + : new SparseFieldSetElementExpression(_field!, ToChildrenDictionary()); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs index 246b1ce8e1..2f27a85587 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -58,20 +58,27 @@ internal void WriteSelection(IndentingStringWriter writer, bool toFullString) foreach (ResourceType type in GetResourceTypes()) { writer.WriteLine($"{nameof(FieldSelectors)}<{type.ClrType.Name}>"); - WriterSelectors(writer, toFullString, type); + WriteSelectors(writer, toFullString, type); } } } - private void WriterSelectors(IndentingStringWriter writer, bool toFullString, ResourceType type) + private void WriteSelectors(IndentingStringWriter writer, bool toFullString, ResourceType type) { + FieldSelectors selectors = GetOrCreateSelectors(type); + using (writer.Indent()) { - foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in GetOrCreateSelectors(type)) + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in selectors) { if (nextLayer == null) { writer.WriteLine(toFullString ? field.ToFullString() : field.ToString()); + + if (field is AttrAttribute attr && selectors.GetChildSelectors(attr) is { } childSelectors) + { + WriteChildSelectors(writer, toFullString, childSelectors); + } } else { @@ -81,4 +88,20 @@ private void WriterSelectors(IndentingStringWriter writer, bool toFullString, Re } } } + + private static void WriteChildSelectors(IndentingStringWriter writer, bool toFullString, FieldSelectors childSelectors) + { + using (writer.Indent()) + { + foreach (ResourceFieldAttribute field in childSelectors.Keys) + { + writer.WriteLine(toFullString ? field.ToFullString() : field.ToString()); + + if (field is AttrAttribute attr && childSelectors.GetChildSelectors(attr) is { } nextChildren) + { + WriteChildSelectors(writer, toFullString, nextChildren); + } + } + } + } } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index d9ab815638..6f921fd6bd 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; @@ -10,8 +11,21 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class FieldSelectors : Dictionary { + private Dictionary? _childSelectors; + public bool IsEmpty => Count == 0; + /// + /// Indicates whether any child selectors have been configured for compound attributes. + /// + public bool HasChildSelectors => _childSelectors is { Count: > 0 }; + + /// + /// The configured child selectors keyed by their parent compound attribute. + /// + public IReadOnlyDictionary ChildSelectors => + _childSelectors ?? (IReadOnlyDictionary)ImmutableDictionary.Empty; + public bool ContainsReadOnlyAttribute { get @@ -28,6 +42,32 @@ public bool ContainsOnlyRelationships } } + /// + /// Gets child selectors for the specified compound attribute, or null if not present. + /// + public FieldSelectors? GetChildSelectors(AttrAttribute compoundAttribute) + { + ArgumentNullException.ThrowIfNull(compoundAttribute); + return _childSelectors?.GetValueOrDefault(compoundAttribute); + } + + /// + /// Gets or creates child selectors for the specified compound attribute. + /// + public FieldSelectors GetOrCreateChildSelectors(AttrAttribute compoundAttribute) + { + ArgumentNullException.ThrowIfNull(compoundAttribute); + _childSelectors ??= new Dictionary(); + + if (!_childSelectors.TryGetValue(compoundAttribute, out FieldSelectors? childSelectors)) + { + childSelectors = new FieldSelectors(); + _childSelectors[compoundAttribute] = childSelectors; + } + + return childSelectors; + } + public bool ContainsField(ResourceFieldAttribute field) { ArgumentNullException.ThrowIfNull(field); @@ -62,10 +102,11 @@ public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer q public void RemoveAttributes() { - while (this.Any(pair => pair.Key is AttrAttribute)) + foreach (ResourceFieldAttribute field in Keys.Where(key => key is AttrAttribute).ToArray()) { - ResourceFieldAttribute field = this.First(pair => pair.Key is AttrAttribute).Key; Remove(field); } + + _childSelectors?.Clear(); } } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index da65dbedc5..9c7ebd472f 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -11,13 +11,6 @@ namespace JsonApiDotNetCore.Queries; /// public interface IQueryLayerComposer { - /// - /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. - /// - [Obsolete("This method is no longer used and will be removed in a future version.")] - // ReSharper disable once UnusedMemberInSuper.Global - FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); - /// /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. /// diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs deleted file mode 100644 index 22046d3bca..0000000000 --- a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries; - -/// -/// Takes sparse fieldsets from s and invokes -/// on them. -/// -/// -/// This cache ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The -/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which -/// fields to write to the response body. -/// -public interface ISparseFieldSetCache -{ - /// - /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields. - /// - IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType); - - /// - /// Gets the set of attributes to retrieve from the underlying data store for relationship endpoints. This always returns 'id', along with any additional - /// attributes from resource definition callback. - /// - IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType); - - /// - /// Gets the evaluated set of sparse fields to serialize into the response body. - /// - IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType); - - /// - /// Resets the cached results from resource definition callbacks. - /// - void Reset(); -} diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs new file mode 100644 index 0000000000..080684f9f6 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs @@ -0,0 +1,32 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Queries; + +/// +/// Takes sparse fieldsets from s and invokes +/// on them. +/// +/// +/// This service ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The +/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which +/// fields to write to the response body. +/// +public interface ISparseFieldSetEvaluator +{ + /// + /// Gets the sparse fieldset to retrieve from the underlying data store, or null to retrieve all fields. + /// + SparseFieldSetExpression? GetSparseFieldSetForQuery(ResourceType resourceType); + + /// + /// Gets the evaluated sparse fieldset to serialize into the response body. + /// + SparseFieldSetExpression GetSparseFieldSetForSerializer(ResourceType resourceType); + + /// + /// Resets the cached results from resource definition callbacks. + /// + void Reset(); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs new file mode 100644 index 0000000000..7cb242d212 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Represents a filter scope on a resource or compound type container. +/// +[PublicAPI] +public sealed class AttributeContainerFilterScope : FilterScope +{ + /// + /// Gets the container in scope. + /// + public IAttributeContainer Container { get; } + + public AttributeContainerFilterScope(IAttributeContainer container) + { + ArgumentNullException.ThrowIfNull(container); + + Container = container; + } + + /// + public override string? ToString() + { + return Container.ToString(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 3001c19296..e5416f9183 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics; using Humanizer; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -34,21 +35,30 @@ public class FilterParser : QueryExpressionParser, IFilterParser ]; private readonly IResourceFactory _resourceFactory; - private readonly Stack _resourceTypeStack = new(); + private readonly Stack _scopeStack = new(); + + protected QueryParseChoices FieldOrElementChoice => + CurrentScope switch + { + ScalarCollectionElementFilterScope => QueryParseChoices.Underscore, + AttributeContainerFilterScope => QueryParseChoices.FieldName, + _ => throw new UnreachableException() + }; /// - /// Gets the resource type currently in scope. Call to temporarily change the current resource type. + /// Gets the current filter scope. Call or to temporarily change the current + /// scope. /// - protected ResourceType ResourceTypeInScope + protected FilterScope CurrentScope { get { - if (_resourceTypeStack.Count == 0) + if (_scopeStack.Count == 0) { - throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + throw new InvalidOperationException($"No filter scope is currently active. Call {nameof(Parse)}() first."); } - return _resourceTypeStack.Peek(); + return _scopeStack.Peek(); } } @@ -66,19 +76,17 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); - _resourceTypeStack.Clear(); - FilterExpression expression; + _scopeStack.Clear(); - using (InScopeOfResourceType(resourceType)) + using (InScopeOfContainer(resourceType)) { - expression = ParseFilter(); + FilterExpression expression = ParseFilter(); AssertTokenStackIsEmpty(); - } - - AssertResourceTypeStackIsEmpty(); + AssertFilterScopeStackHasOnlyRoot(); - return expression; + return expression; + } } protected virtual bool IsFunction(string name) @@ -90,27 +98,26 @@ protected virtual bool IsFunction(string name) protected virtual FunctionExpression ParseFunction() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count }) { - switch (nextToken.Value) - { - case Keywords.Count: - { - return ParseCount(); - } - } + return ParseCount(); } return ParseFilter(); } - private CountExpression ParseCount() + protected virtual CountExpression ParseCount() { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'count' cannot be used on scalar collection elements.", position); + } + EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, containerScope.Container); EatSingleCharacterToken(TokenKind.CloseParen); @@ -162,7 +169,7 @@ protected virtual FilterExpression ParseFilter() } int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Filter function expected.", position); + throw new QueryParseException(QueryParseChoices.FilterFunction, position); } protected virtual NotExpression ParseNot() @@ -194,10 +201,8 @@ protected virtual LogicalExpression ParseLogical(string operatorName) term = ParseFilter(); termsBuilder.Add(term); - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + while (TryEatSingleCharacterToken(TokenKind.Comma)) { - EatSingleCharacterToken(TokenKind.Comma); - term = ParseFilter(); termsBuilder.Add(term); } @@ -213,43 +218,82 @@ protected virtual ComparisonExpression ParseComparison(string operatorName) ArgumentException.ThrowIfNullOrEmpty(operatorName); var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + bool isEqualityComparison = comparisonOperator == ComparisonOperator.Equals; EatText(operatorName); EatSingleCharacterToken(TokenKind.OpenParen); - QueryExpression leftTerm = ParseComparisonLeftTerm(comparisonOperator); + QueryExpression leftTerm = ParseComparisonLeftTerm(isEqualityComparison); EatSingleCharacterToken(TokenKind.Comma); - QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm); + QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm, isEqualityComparison); EatSingleCharacterToken(TokenKind.CloseParen); return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); } - private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator) + private QueryExpression ParseComparisonLeftTerm(bool isEqualityComparison) { 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; + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) + { + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + if (elementValue != null) + { + return elementValue; + } + } + else if (CurrentScope is AttributeContainerFilterScope containerScope) + { + if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) + { + // Allow equality comparison of a to-one relationship or attribute with null. + FieldChainPattern pattern = isEqualityComparison + ? BuiltInPatterns.ToOneOrCompoundChainEndingInToOneOrAttribute + : BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute; + + return ParseFieldChain(pattern, containerScope.Container); + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); + } + + /// + /// Attempts to parse the underscore (_), which represents the current scalar collection element. + /// + protected ScalarCollectionElementValueExpression? TryParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope) + { + ArgumentNullException.ThrowIfNull(collectionScope); + + if (TryEatSingleCharacterToken(TokenKind.Underscore)) + { + return new ScalarCollectionElementValueExpression(collectionScope.ElementType); + } + + return null; } - private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) + private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm, bool isEqualityComparison) { if (leftTerm is ResourceFieldChainExpression leftFieldChain) { ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; if (leftLastField is HasOneAttribute) + { + return ParseToOneRelationshipComparisonRightTerm(); + } + + if (leftLastField is AttrAttribute { IsCompound: true } or AttrAttribute { IsCollection: true }) { return ParseNull(); } @@ -257,29 +301,66 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) var leftAttribute = (AttrAttribute)leftLastField; ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(leftAttribute); - return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter); + return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter, isEqualityComparison); } if (leftTerm is FunctionExpression leftFunction) { ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(leftFunction.ReturnType); - return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter); + return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter, isEqualityComparison); + } + + if (leftTerm is ScalarCollectionElementValueExpression collectionElement) + { + ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(collectionElement.ElementType); + return ParseTypedComparisonRightTerm(collectionElement.ElementType, constantValueConverter, isEqualityComparison); } - throw new InvalidOperationException( - $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + throw new UnreachableException(); } - private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter) + private QueryExpression ParseToOneRelationshipComparisonRightTerm() { - bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + throw new UnreachableException(); + } - 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 (IsNullConstantToken(nextToken)) + { + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (nextToken.Kind == TokenKind.Text && !IsFunction(nextToken.Value!)) + { + return ParseFieldChain(BuiltInPatterns.ToOneChain, containerScope.Container); + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.Null, position); + } + + private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter, bool isEqualityComparison) + { + // Always allowing to compare equality with null makes the JSON:API contract resilient to internal implementation details. + // This unifies the behavior for all attributes types (scalar/compound/collection) across nullability of reference/value types. + // Comparing a non-nullable value type to null is a harmless predicate that will simply never match any database rows. + bool allowNull = isEqualityComparison || RuntimeTypeConverter.CanContainNull(leftType); + + QueryParseChoices rightTermChoices = QueryParseChoices.Function | FieldOrElementChoice | QueryParseChoices.QuotedText; + + if (allowNull) + { + rightTermChoices |= QueryParseChoices.Null; + } if (TokenStack.TryPeek(out Token? nextToken)) { - if (nextToken is { Kind: TokenKind.QuotedText }) + if (nextToken.Kind == TokenKind.QuotedText) { TokenStack.Pop(); @@ -287,30 +368,43 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return new LiteralConstantExpression(constantValue, nextToken.Value!); } - if (nextToken.Kind == TokenKind.Text) + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) { - if (nextToken.Value == Keywords.Null) + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue != null) { - if (!allowNull) - { - throw new QueryParseException(errorMessage, nextToken.Position); - } + return elementValue; + } + } - TokenStack.Pop(); - return NullConstantExpression.Instance; + if (IsNullConstantToken(nextToken)) + { + if (!allowNull) + { + throw new QueryParseException(rightTermChoices, nextToken.Position); } + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (nextToken.Kind == TokenKind.Text) + { if (IsFunction(nextToken.Value!)) { return ParseFunction(); } - return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + if (CurrentScope is AttributeContainerFilterScope containerScope) + { + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, containerScope.Container); + } } } int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException(errorMessage, position); + throw new QueryParseException(rightTermChoices, position); } protected virtual MatchTextExpression ParseTextMatch(string operatorName) @@ -341,26 +435,58 @@ private QueryExpression ParseTextMatchLeftTerm() if (targetFunction.ReturnType != typeof(string)) { - throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(targetFunction.ReturnType); + throw new QueryParseException($"Function returns type '{actualTypeName}' while type 'String' was expected.", nextToken.Position); } return targetFunction; } - int chainStartPosition = GetNextTokenPositionOrEnd(); + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) + { + int elementPosition = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue != null) + { + if (elementValue.ElementType != typeof(string)) + { + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(elementValue.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", elementPosition); + } - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, null); + return elementValue; + } - var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, elementPosition); + } - if (targetAttribute.Property.PropertyType != typeof(string)) + if (CurrentScope is AttributeContainerFilterScope containerScope) { - int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); - throw new QueryParseException("Attribute of type 'String' expected.", position); + if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) + { + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + containerScope.Container); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + if (targetAttribute.Property.PropertyType != typeof(string)) + { + int attrPosition = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(targetAttribute.Property.PropertyType); + + throw new QueryParseException($"Attribute '{targetAttribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.", + attrPosition); + } + + return targetAttributeChain; + } } - return targetAttributeChain; + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); } protected virtual AnyExpression ParseAny() @@ -378,10 +504,8 @@ protected virtual AnyExpression ParseAny() LiteralConstantExpression constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + while (TryEatSingleCharacterToken(TokenKind.Comma)) { - EatSingleCharacterToken(TokenKind.Comma); - constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); } @@ -399,38 +523,79 @@ protected virtual AnyExpression ParseAny() { FunctionExpression targetFunction = ParseFunction(); - Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); - return (targetFunction, functionConverterFactory); + Func converterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); + return (targetFunction, converterFactory); } - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) + { + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue != null) + { + Func converterFactory = () => GetConstantValueConverterForType(elementValue.ElementType); + return (elementValue, converterFactory); + } + } + else if (CurrentScope is AttributeContainerFilterScope containerScope) + { + if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) + { + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + containerScope.Container); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; - var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + Func converterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); + return (targetAttributeChain, converterFactory); + } + } - Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); - return (targetAttributeChain, attributeConverterFactory); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); } protected virtual HasExpression ParseHas() { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'has' cannot be used on scalar collection elements.", position); + } + EatText(Keywords.Has); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, containerScope.Container); FilterExpression? filter = null; - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + if (TryEatSingleCharacterToken(TokenKind.Comma)) { - EatSingleCharacterToken(TokenKind.Comma); - - var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1]; + ResourceFieldAttribute lastField = targetCollection.Fields[^1]; - using (InScopeOfResourceType(hasManyRelationship.RightType)) + if (lastField is HasManyAttribute hasManyRelationship) { - filter = ParseFilter(); + using (InScopeOfContainer(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + else if (lastField is AttrAttribute { IsCompound: true, IsCollection: true } compoundCollectionAttr) + { + using (InScopeOfContainer(compoundCollectionAttr.CompoundType!)) + { + filter = ParseFilter(); + } + } + else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } scalarCollectionAttr) + { + Type elementType = CollectionConverter.Instance.FindCollectionElementType(scalarCollectionAttr.Property.PropertyType)!; + + using (InScopeOfScalarCollection(elementType)) + { + filter = ParseFilter(); + } } } @@ -441,14 +606,20 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { + if (CurrentScope is not AttributeContainerFilterScope { Container: ResourceType resourceTypeInScope }) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'isType' can only be used on resource types.", position); + } + EatText(Keywords.IsType); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + ResourceFieldChainExpression? targetToOneRelationship = TryParseCommaSeparatedSequenceOfToOneRelationshipChains(resourceTypeInScope); EatSingleCharacterToken(TokenKind.Comma); - ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope; + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : resourceTypeInScope; ResourceType derivedType = ParseDerivedType(baseType); FilterExpression? child = TryParseFilterInIsType(derivedType); @@ -458,14 +629,23 @@ protected virtual IsTypeExpression ParseIsType() return new IsTypeExpression(targetToOneRelationship, derivedType, child); } - private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + private ResourceFieldChainExpression? TryParseCommaSeparatedSequenceOfToOneRelationshipChains(ResourceType resourceTypeInScope) { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + if (TokenStack.TryPeek(out Token? nextToken)) { - return null; + if (nextToken.Kind == TokenKind.Comma) + { + return null; + } + + if (nextToken.Kind == TokenKind.Text && !IsNullConstantToken(nextToken)) + { + return ParseFieldChain(BuiltInPatterns.ToOneChain, resourceTypeInScope); + } } - return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.Comma, position); } private ResourceType ParseDerivedType(ResourceType baseType) @@ -478,7 +658,7 @@ private ResourceType ParseDerivedType(ResourceType baseType) return ResolveDerivedType(baseType, derivedTypeName, token.Position); } - throw new QueryParseException("Resource type expected.", position); + throw new QueryParseException(QueryParseChoices.ResourceType, position); } private static ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName, int position) @@ -517,11 +697,9 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der { FilterExpression? filter = null; - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + if (TryEatSingleCharacterToken(TokenKind.Comma)) { - EatSingleCharacterToken(TokenKind.Comma); - - using (InScopeOfResourceType(derivedType)) + using (InScopeOfContainer(derivedType)) { filter = ParseFilter(); } @@ -530,8 +708,10 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der return filter; } - private LiteralConstantExpression ParseConstant(ConstantValueConverter constantValueConverter) + protected virtual LiteralConstantExpression ParseConstant(ConstantValueConverter constantValueConverter) { + ArgumentNullException.ThrowIfNull(constantValueConverter); + int position = GetNextTokenPositionOrEnd(); if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) @@ -540,19 +720,19 @@ private LiteralConstantExpression ParseConstant(ConstantValueConverter constantV return new LiteralConstantExpression(constantValue, token.Value!); } - throw new QueryParseException("Value between quotes expected.", position); + throw new QueryParseException(QueryParseChoices.QuotedText, position); } - private NullConstantExpression ParseNull() + protected virtual NullConstantExpression ParseNull() { int position = GetNextTokenPositionOrEnd(); - if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null }) + if (TokenStack.TryPop(out Token? token) && IsNullConstantToken(token)) { return NullConstantExpression.Instance; } - throw new QueryParseException("null expected.", position); + throw new QueryParseException(QueryParseChoices.Null, position); } protected virtual ConstantValueConverter GetConstantValueConverterForType(Type destinationType) @@ -568,20 +748,20 @@ protected virtual ConstantValueConverter GetConstantValueConverterForType(Type d catch (FormatException exception) { string destinationTypeName = RuntimeTypeConverter.GetFriendlyTypeName(destinationType); - throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationTypeName}'.", position, exception); + throw new QueryParseException($"Failed to convert '{stringValue}' to type '{destinationTypeName}'.", position, exception); } }; } private ConstantValueConverter GetConstantValueConverterForAttribute(AttrAttribute attribute) { - if (attribute is { Property.Name: nameof(Identifiable<>.Id) }) + if (attribute is { Property.Name: nameof(Identifiable<>.Id), ContainingType: ResourceType resourceType }) { return (stringValue, position) => { try { - return DeObfuscateStringId(attribute.Type, stringValue); + return DeObfuscateStringId(resourceType, stringValue); } catch (JsonApiException exception) { @@ -612,32 +792,60 @@ protected override void ValidateField(ResourceFieldAttribute field, int position } /// - /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed. + /// Changes the current filter scope to the specified attribute container (resource type or compound type) and restores the original scope when the + /// return value is disposed. /// - protected IDisposable InScopeOfResourceType(ResourceType resourceType) + protected IDisposable InScopeOfContainer(IAttributeContainer container) { - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); + + var scope = new AttributeContainerFilterScope(container); + return PushFilterScope(scope); + } + + /// + /// Changes the current filter scope to the specified scalar attribute collection type and restores the original scope when the return value is disposed. + /// + protected IDisposable InScopeOfScalarCollection(Type collectionElementType) + { + ArgumentNullException.ThrowIfNull(collectionElementType); - _resourceTypeStack.Push(resourceType); - return new PopResourceTypeOnDispose(_resourceTypeStack); + var scope = new ScalarCollectionElementFilterScope(collectionElementType); + return PushFilterScope(scope); } - private void AssertResourceTypeStackIsEmpty() + private PopFilterScopeOnDispose PushFilterScope(FilterScope scope) { - if (_resourceTypeStack.Count > 0) + _scopeStack.Push(scope); + return new PopFilterScopeOnDispose(_scopeStack); + } + + private void AssertFilterScopeStackHasOnlyRoot() + { + if (_scopeStack.Count > 1) { - 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)}()."); + FilterScope scopeAtTopOfStack = _scopeStack.Peek(); + + if (scopeAtTopOfStack is AttributeContainerFilterScope containerScope) + { + string containerType = containerScope.Container is ResourceType ? "resource type" : "compound type"; + + throw new InvalidOperationException($"There is still a {containerType} in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfContainer)}()."); + } + + throw new InvalidOperationException("There is still a scalar collection element type in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfScalarCollection)}()."); } } - private sealed class PopResourceTypeOnDispose(Stack resourceTypeStack) : IDisposable + private sealed class PopFilterScopeOnDispose(Stack scopeStack) : IDisposable { - private readonly Stack _resourceTypeStack = resourceTypeStack; + private readonly Stack _scopeStack = scopeStack; public void Dispose() { - _resourceTypeStack.Pop(); + _scopeStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs new file mode 100644 index 0000000000..5ad6402fdb --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs @@ -0,0 +1,14 @@ +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Represents the lexical scope within a filter expression. +/// +public abstract class FilterScope +{ + /// + /// This constructor prevents external inheritance. + /// + private protected FilterScope() + { + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs index acec82b8f2..721af2ebe1 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs @@ -17,5 +17,5 @@ public interface ISparseFieldSetParser /// /// The resource type used to lookup JSON:API fields that are referenced in . /// - SparseFieldSetExpression? Parse(string source, ResourceType resourceType); + SparseFieldSetExpression Parse(string source, ResourceType resourceType); } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index f8338c93c2..cd573bfd5c 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -60,7 +60,9 @@ private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression include 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}.", position); + + throw new QueryParseException( + $"Including '{path}' has a depth of {parentChain.Count}, which exceeds the maximum inclusion depth of {maximumDepth}.", position); } foreach (IncludeElementExpression child in includeElement.Children) diff --git a/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs index d6199dade7..688f405ce3 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs @@ -56,45 +56,40 @@ protected virtual PaginationElementQueryStringValueExpression ParsePaginationEle return new PaginationElementQueryStringValueExpression(null, number.Value, position); } - IncludeExpression scope = ParseRelationshipChainEndingInToMany(resourceType, "Number or relationship name expected."); + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && !IsNullConstantToken(nextToken)) + { + IncludeExpression scope = ParseRelationshipChainEndingInToMany(resourceType); - EatSingleCharacterToken(TokenKind.Colon); + EatSingleCharacterToken(TokenKind.Colon); - position = GetNextTokenPositionOrEnd(); - number = TryParseNumber(); + position = GetNextTokenPositionOrEnd(); + number = TryParseNumber(); - if (number == null) - { - throw new QueryParseException("Number expected.", position); + if (number == null) + { + throw new QueryParseException(QueryParseChoices.Number, position); + } + + return new PaginationElementQueryStringValueExpression(scope, number.Value, position); } - return new PaginationElementQueryStringValueExpression(scope, number.Value, position); + throw new QueryParseException(QueryParseChoices.Number | QueryParseChoices.ToManyRelationshipName, position); } private int? TryParseNumber() { - if (TokenStack.TryPeek(out Token? nextToken)) - { - int number; - - if (nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - int position = GetNextTokenPositionOrEnd(); + bool isNegative = TryEatSingleCharacterToken(TokenKind.Minus); - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) - { - return -number; - } - - throw new QueryParseException("Digits expected.", position); - } + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out int number)) + { + TokenStack.Pop(); + return isNegative ? -number : number; + } - if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) - { - TokenStack.Pop(); - return number; - } + if (isNegative) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Number, position); } return null; diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index 46fa2c8273..b57e8a31fa 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -1,9 +1,9 @@ using System.Collections.Immutable; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; @@ -69,25 +69,35 @@ private static Stack TokensToStack(Token[] tokens) return stack; } + /// + /// Indicates whether the specified token represents the 'null' constant keyword. + /// + protected static bool IsNullConstantToken([NotNullWhen(true)] Token? token) + { + return token is { Kind: TokenKind.Text, Value: Keywords.Null }; + } + /// /// 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) + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, IAttributeContainer container, + FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None) { ArgumentNullException.ThrowIfNull(pattern); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); int startPosition = GetNextTokenPositionOrEnd(); - string path = EatFieldChain(alternativeErrorMessage); - PatternMatchResult result = pattern.Match(path, resourceType, options); + string path = EatFieldChain(); + PatternMatchResult result = pattern.Match(path, container, options); if (!result.IsSuccess) { + string containerDescription = container is ResourceType resourceType ? $"resource type '{resourceType}'" : $"compound type '{container}'"; + string message = result.IsFieldChainError ? result.FailureMessage - : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; + : $"Field chain on {containerDescription} failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; throw new QueryParseException(message, startPosition + result.FailurePosition); } @@ -104,7 +114,7 @@ protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern return new ResourceFieldChainExpression(result.FieldChain.ToImmutableArray()); } - private string EatFieldChain(string? alternativeErrorMessage) + private string EatFieldChain() { var pathBuilder = new StringBuilder(); @@ -112,13 +122,12 @@ private string EatFieldChain(string? alternativeErrorMessage) { int position = GetNextTokenPositionOrEnd(); - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) { pathBuilder.Append(token.Value); - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + if (TryEatSingleCharacterToken(TokenKind.Period)) { - EatSingleCharacterToken(TokenKind.Period); pathBuilder.Append('.'); } else @@ -128,7 +137,7 @@ private string EatFieldChain(string? alternativeErrorMessage) } else { - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position); + throw new QueryParseException(QueryParseChoices.FieldName, position); } } } @@ -137,7 +146,7 @@ private string EatFieldChain(string? alternativeErrorMessage) /// 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) + protected virtual void EatText(string text) { ArgumentNullException.ThrowIfNull(text); @@ -149,8 +158,24 @@ protected void EatText(string text) } /// - /// 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. + /// Consumes a token containing the expected text from the top of if present. + /// + protected virtual bool TryEatText(string text) + { + ArgumentNullException.ThrowIfNull(text); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == text) + { + TokenStack.Pop(); + return true; + } + + return false; + } + + /// + /// Consumes the expected single-character 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) { @@ -166,6 +191,25 @@ protected virtual void EatSingleCharacterToken(TokenKind kind) } } + /// + /// Consumes the expected single-character token kind from the top of if present. + /// + protected virtual bool TryEatSingleCharacterToken(TokenKind kind) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == kind) + { + if (!QueryTokenizer.TokenKindToSingleCharacterLookup.TryGetValue(kind, out _)) + { + throw new InvalidOperationException($"Token kind '{kind}' is not a single-character token."); + } + + TokenStack.Pop(); + return true; + } + + return false; + } + /// /// 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. @@ -206,7 +250,7 @@ protected void AssertTokenStackIsEmpty() if (TokenStack.Count > 0) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("End of expression expected.", position); + throw new QueryParseException(QueryParseChoices.EndOfExpression, position); } } @@ -231,7 +275,7 @@ protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(Reso isAtStart = false; } - ParseRelationshipChain(treeRoot, false, null); + ParseRelationshipChain(treeRoot, false, true); } return treeRoot.ToExpression(); @@ -240,17 +284,17 @@ protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(Reso /// /// Parses a relationship chain that ends in a to-many relationship, taking relationships on derived types into account. /// - protected IncludeExpression ParseRelationshipChainEndingInToMany(ResourceType resourceType, string? alternativeErrorMessage) + protected IncludeExpression ParseRelationshipChainEndingInToMany(ResourceType resourceType) { ArgumentNullException.ThrowIfNull(resourceType); var treeRoot = IncludeTreeNode.CreateRoot(resourceType); - ParseRelationshipChain(treeRoot, true, alternativeErrorMessage); + ParseRelationshipChain(treeRoot, true, false); return treeRoot.ToExpression(); } - private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainEndsInToMany, string? alternativeErrorMessage) + private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainEndsInToMany, bool validateCanInclude) { // 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: @@ -278,18 +322,16 @@ private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainE // that there's currently no way to include Products without Articles. We could add such optional upcast syntax // in the future, if desired. - ReadOnlyCollection children = ParseRelationshipName([treeRoot], requireChainEndsInToMany, alternativeErrorMessage); + ReadOnlyCollection children = ParseRelationshipName([treeRoot], requireChainEndsInToMany, validateCanInclude); - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + while (TryEatSingleCharacterToken(TokenKind.Period)) { - EatSingleCharacterToken(TokenKind.Period); - - children = ParseRelationshipName(children, requireChainEndsInToMany, null); + children = ParseRelationshipName(children, requireChainEndsInToMany, validateCanInclude); } } private ReadOnlyCollection ParseRelationshipName(IReadOnlyCollection parents, bool requireChainEndsInToMany, - string? alternativeErrorMessage) + bool validateCanInclude) { int position = GetNextTokenPositionOrEnd(); @@ -298,15 +340,15 @@ private ReadOnlyCollection ParseRelationshipName(IReadOnlyColle bool isAtEndOfChain = !TokenStack.TryPeek(out Token? nextToken) || nextToken.Kind != TokenKind.Period; bool requireToMany = requireChainEndsInToMany && isAtEndOfChain; - return LookupRelationshipName(token.Value!, parents, requireToMany, position); + return LookupRelationshipName(token.Value!, parents, requireToMany, validateCanInclude, position); } - string message = alternativeErrorMessage ?? (requireChainEndsInToMany ? "To-many relationship name expected." : "Relationship name expected."); - throw new QueryParseException(message, position); + QueryParseChoices choices = requireChainEndsInToMany ? QueryParseChoices.ToManyRelationshipName : QueryParseChoices.RelationshipName; + throw new QueryParseException(choices, position); } private ReadOnlyCollection LookupRelationshipName(string relationshipName, IReadOnlyCollection parents, - bool requireToMany, int position) + bool requireToMany, bool validateCanInclude, int position) { List children = []; HashSet relationshipsFound = []; @@ -321,14 +363,21 @@ private ReadOnlyCollection LookupRelationshipName(string relati { relationshipsFound.UnionWith(relationships); - RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray(); - ReadOnlyCollection affectedChildren = parent.EnsureChildren(relationshipsToInclude); + RelationshipAttribute[] relationshipsToAttach = validateCanInclude + ? relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray() + : relationships.ToArray(); + + ReadOnlyCollection affectedChildren = parent.EnsureChildren(relationshipsToAttach); children.AddRange(affectedChildren); } } AssertRelationshipsFound(relationshipsFound, relationshipName, requireToMany, parents, position); - AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, position); + + if (validateCanInclude) + { + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, position); + } return children.AsReadOnly(); } @@ -396,22 +445,17 @@ private static string GetErrorMessageForNoneFound(string relationshipName, bool builder.Append($" does not exist on any of the resource types {typeNames}"); } - builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + builder.Append(hasDerivedTypes ? parentResourceTypes.Length == 1 ? " or any of its derived types." : " or any of their derived types." : "."); return builder.ToString(); } - private void AssertAtLeastOneCanBeIncluded(HashSet relationshipsFound, string relationshipName, int position) + private static void AssertAtLeastOneCanBeIncluded(HashSet relationshipsFound, string relationshipName, int position) { if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked())) { ResourceType resourceType = relationshipsFound.First().LeftType; - string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed."; - - var exception = new QueryParseException(message, position); - string specificMessage = exception.GetMessageWithPosition(Source); - - throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage); + throw new QueryParseException($"Including relationship '{relationshipName}' on '{resourceType}' is not allowed.", position); } } @@ -452,7 +496,7 @@ public IncludeExpression ToExpression() if (element.Relationship is HiddenRootRelationshipAttribute) { - return element.Children.Count > 0 ? new IncludeExpression(element.Children) : IncludeExpression.Empty; + return IncludeExpression.Create(element.Children); } return new IncludeExpression(ImmutableHashSet.Create(element)); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs new file mode 100644 index 0000000000..57eedb3da9 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs @@ -0,0 +1,149 @@ +using System.Text; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Formats a set of and optional custom choices into a human-readable error message. +/// +internal static class QueryParseChoiceFormatter +{ + public static string Format(QueryParseChoices choices, params ReadOnlySpan customChoices) + { + List choiceList = []; + + if (choices.HasFlag(QueryParseChoices.Minus)) + { + choiceList.Add(new ChoiceValue("-")); + } + + if (choices.HasFlag(QueryParseChoices.OpenParenthesis)) + { + choiceList.Add(new ChoiceValue("(")); + } + + if (choices.HasFlag(QueryParseChoices.FilterFunction)) + { + choiceList.Add(new ChoiceValue("filter function")); + } + + if (choices.HasFlag(QueryParseChoices.CountFunction)) + { + choiceList.Add(new ChoiceValue("count function", true)); + } + + if (choices.HasFlag(QueryParseChoices.Function)) + { + choiceList.Add(new ChoiceValue("function")); + } + + if (choices.HasFlag(QueryParseChoices.Number)) + { + choiceList.Add(new ChoiceValue("number")); + } + + if (choices.HasFlag(QueryParseChoices.ParameterName)) + { + choiceList.Add(new ChoiceValue("parameter name")); + } + + if (choices.HasFlag(QueryParseChoices.ResourceType)) + { + choiceList.Add(new ChoiceValue("resource type")); + } + + if (choices.HasFlag(QueryParseChoices.FieldName)) + { + choiceList.Add(new ChoiceValue("field name")); + } + + if (choices.HasFlag(QueryParseChoices.RelationshipName)) + { + choiceList.Add(new ChoiceValue("relationship name")); + } + else if (choices.HasFlag(QueryParseChoices.ToOneRelationshipName)) + { + choiceList.Add(new ChoiceValue("to-one relationship name")); + } + else if (choices.HasFlag(QueryParseChoices.ToManyRelationshipName)) + { + choiceList.Add(new ChoiceValue("to-many relationship name")); + } + + if (choices.HasFlag(QueryParseChoices.QuotedText)) + { + choiceList.Add(new ChoiceValue("value between quotes")); + } + + if (choices.HasFlag(QueryParseChoices.Underscore)) + { + choiceList.Add(new ChoiceValue("_")); + } + + if (choices.HasFlag(QueryParseChoices.Null)) + { + choiceList.Add(new ChoiceValue(Keywords.Null, true)); + } + + if (choices.HasFlag(QueryParseChoices.Comma)) + { + choiceList.Add(new ChoiceValue(",")); + } + + if (choices.HasFlag(QueryParseChoices.EndOfExpression)) + { + choiceList.Add(new ChoiceValue("end of expression")); + } + + foreach (string customChoice in customChoices) + { + if (!string.IsNullOrWhiteSpace(customChoice)) + { + choiceList.Add(new ChoiceValue(customChoice, true)); + } + } + + return FormatChoices(choiceList); + } + + private static string FormatChoices(List choices) + { + var builder = new StringBuilder(); + + if (choices.Count > 0) + { + for (int index = 0; index < choices.Count; index++) + { + ChoiceValue choiceValue = choices[index]; + + if (index == 0) + { + if (choiceValue.PreserveCase) + { + builder.Append(choiceValue.Text); + } + else + { + builder.Append(char.ToUpperInvariant(choiceValue.Text[0])); + builder.Append(choiceValue.Text.AsSpan(1)); + } + } + else if (index == choices.Count - 1) + { + builder.Append(" or "); + builder.Append(choiceValue.Text); + } + else + { + builder.Append(", "); + builder.Append(choiceValue.Text); + } + } + + builder.Append(" expected."); + } + + return builder.ToString(); + } + + private readonly record struct ChoiceValue(string Text, bool PreserveCase = false); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs new file mode 100644 index 0000000000..24f904bbd8 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs @@ -0,0 +1,27 @@ +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Lists the grammatical choices that can be expected at a token position in a query string expression. +/// +[Flags] +public enum QueryParseChoices +{ + None = 0, + Minus = 1 << 0, + OpenParenthesis = 1 << 1, + FilterFunction = 1 << 2, + CountFunction = 1 << 3, + Function = 1 << 4, + Number = 1 << 5, + ParameterName = 1 << 6, + ResourceType = 1 << 7, + FieldName = 1 << 8, + ToOneRelationshipName = 1 << 9, + ToManyRelationshipName = 1 << 10, + RelationshipName = ToOneRelationshipName | ToManyRelationshipName, + QuotedText = 1 << 11, + Underscore = 1 << 12, + Null = 1 << 13, + Comma = 1 << 14, + EndOfExpression = 1 << 15 +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs index 4f63f45e24..3f841f7431 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs @@ -20,6 +20,11 @@ public QueryParseException(string message, int position) Position = position; } + public QueryParseException(QueryParseChoices choices, int position, params ReadOnlySpan customChoices) + : this(QueryParseChoiceFormatter.Format(choices, customChoices), position) + { + } + public QueryParseException(string message, int position, Exception innerException) : base(message, innerException) { diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs index 5ba95f3d98..e21e031c19 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -30,18 +30,16 @@ protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterS if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { - throw new QueryParseException("Parameter name expected.", position); + throw new QueryParseException(QueryParseChoices.ParameterName, position); } var name = new LiteralConstantExpression(token.Value!); IncludeExpression? scope = null; - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) + if (TryEatSingleCharacterToken(TokenKind.OpenBracket)) { - TokenStack.Pop(); - - scope = ParseRelationshipChainEndingInToMany(resourceType, null); + scope = ParseRelationshipChainEndingInToMany(resourceType); EatSingleCharacterToken(TokenKind.CloseBracket); } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs index bf7b714e96..bdee55584c 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -15,7 +15,8 @@ public sealed class QueryTokenizer ['.'] = TokenKind.Period, [','] = TokenKind.Comma, [':'] = TokenKind.Colon, - ['-'] = TokenKind.Minus + ['-'] = TokenKind.Minus, + ['_'] = TokenKind.Underscore }.AsReadOnly(); internal static readonly IReadOnlyDictionary TokenKindToSingleCharacterLookup = @@ -69,7 +70,7 @@ public IEnumerable EnumerateTokens() { if (_textBuffer.Length > 0) { - throw new QueryParseException("Unexpected ' outside text.", _sourceOffset); + throw new QueryParseException("Unexpected ' outside quoted text.", _sourceOffset); } _isInQuotedSection = true; @@ -79,7 +80,7 @@ public IEnumerable EnumerateTokens() { TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); - if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) + if (singleCharacterTokenKind != null && !IsPunctuationInsideText(singleCharacterTokenKind.Value)) { Token? identifierToken = ProduceTokenFromTextBuffer(false); @@ -108,7 +109,7 @@ public IEnumerable EnumerateTokens() if (_isInQuotedSection) { - throw new QueryParseException("' expected.", _sourceOffset - 1); + throw new QueryParseException("Closing ' expected.", _sourceOffset - 1); } Token? lastToken = ProduceTokenFromTextBuffer(false); @@ -119,9 +120,9 @@ public IEnumerable EnumerateTokens() } } - private bool IsMinusInsideText(TokenKind kind) + private bool IsPunctuationInsideText(TokenKind kind) { - return kind == TokenKind.Minus && _textBuffer.Length > 0; + return kind is TokenKind.Minus or TokenKind.Underscore && _textBuffer.Length > 0; } private char? PeekChar() diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs new file mode 100644 index 0000000000..aa1f0e0dbc --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Represents a filter scope on the element of a scalar collection attribute. +/// +[PublicAPI] +public sealed class ScalarCollectionElementFilterScope : FilterScope +{ + /// + /// Gets the CLR type of the scalar collection element. + /// + public Type ElementType { get; } + + public ScalarCollectionElementFilterScope(Type elementType) + { + ArgumentNullException.ThrowIfNull(elementType); + + ElementType = elementType; + } + + /// + public override string ToString() + { + return ElementType.Name; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index 814a3e4c9e..744141caf9 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -49,12 +49,11 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy { ArgumentNullException.ThrowIfNull(resourceType); - bool isAscending = true; + QueryParseChoices choices = QueryParseChoices.Minus | QueryParseChoices.CountFunction | QueryParseChoices.FieldName; - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) + if (TryEatSingleCharacterToken(TokenKind.Minus)) { - TokenStack.Pop(); - isAscending = false; + choices &= ~QueryParseChoices.Minus; } // An attribute or relationship name usually matches a single field, even when overridden in derived types. @@ -82,19 +81,27 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy // 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 + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) { - string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; - target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + bool isAscending = choices.HasFlag(QueryParseChoices.Minus); + + if (IsFunction(nextToken.Value!)) + { + QueryExpression functionTarget = ParseFunction(resourceType); + return new SortElementExpression(functionTarget, isAscending); + } + + if (!IsNullConstantToken(nextToken)) + { + QueryExpression fieldChainTarget = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, resourceType, + FieldChainPatternMatchOptions.AllowDerivedTypes); + + return new SortElementExpression(fieldChainTarget, isAscending); + } } - return new SortElementExpression(target, isAscending); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(choices, position); } protected virtual bool IsFunction(string name) @@ -120,16 +127,16 @@ protected virtual FunctionExpression ParseFunction(ResourceType resourceType) } int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Count function expected.", position); + throw new QueryParseException(QueryParseChoices.CountFunction, position); } - private CountExpression ParseCount(ResourceType resourceType) + protected virtual CountExpression ParseCount(ResourceType resourceType) { EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, resourceType, + FieldChainPatternMatchOptions.AllowDerivedTypes); EatSingleCharacterToken(TokenKind.CloseParen); @@ -140,7 +147,9 @@ protected override void ValidateField(ResourceFieldAttribute field, int position { ArgumentNullException.ThrowIfNull(field); - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + // Intermediate compound attributes are skipped during capability checks because AllowSort was stripped at startup + // to prevent sorting by whole compound objects, while built-in sort pattern '[OC]*S' ensures only leaf scalar attributes are evaluated. + if (field is AttrAttribute { IsCompound: false, IsCollection: false } attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { throw new QueryParseException($"Sorting on attribute '{attribute}' is not allowed.", position); } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index fb49e9c1dd..bd535fbef7 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; @@ -12,40 +11,42 @@ namespace JsonApiDotNetCore.Queries.Parsing; public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser { /// - public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) + public SparseFieldSetExpression Parse(string source, ResourceType resourceType) { ArgumentNullException.ThrowIfNull(resourceType); Tokenize(source); - SparseFieldSetExpression? expression = ParseSparseFieldSet(resourceType); + SparseFieldSetExpression expression = ParseSparseFieldSet(resourceType); AssertTokenStackIsEmpty(); return expression; } - protected virtual SparseFieldSetExpression? ParseSparseFieldSet(ResourceType resourceType) + protected virtual SparseFieldSetExpression ParseSparseFieldSet(ResourceType resourceType) { ArgumentNullException.ThrowIfNull(resourceType); - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); + var treeBuilder = new SparseFieldTreeBuilder(); + bool isFirstChain = true; while (TokenStack.Count > 0) { - if (fieldSetBuilder.Count > 0) + if (isFirstChain) + { + isFirstChain = false; + } + else { EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = - ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); - - ResourceFieldAttribute nextField = nextChain.Fields.Single(); - fieldSetBuilder.Add(nextField); + ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.CompoundOrCompoundCollectionChainEndingInField, resourceType); + treeBuilder.IncludeChain(nextChain.Fields); } - return fieldSetBuilder.Count > 0 ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; + return treeBuilder.ToExpression(); } protected override void ValidateField(ResourceFieldAttribute field, int position) @@ -55,7 +56,7 @@ protected override void ValidateField(ResourceFieldAttribute field, int position if (field.IsViewBlocked()) { string kind = field is AttrAttribute ? "attribute" : "relationship"; - throw new QueryParseException($"Retrieving the {kind} '{field}' is not allowed.", position); + throw new QueryParseException($"Retrieving {kind} '{field}' is not allowed.", position); } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs index 00c5c6a525..a70ea4ffae 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs @@ -34,7 +34,7 @@ protected virtual ResourceType ParseSparseFieldType() if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { - throw new QueryParseException("Parameter name expected.", position); + throw new QueryParseException(QueryParseChoices.ParameterName, position); } EatSingleCharacterToken(TokenKind.OpenBracket); @@ -55,7 +55,7 @@ private ResourceType ParseResourceType() return GetResourceType(token.Value!, token.Position); } - throw new QueryParseException("Resource type expected.", position); + throw new QueryParseException(QueryParseChoices.ResourceType, position); } private ResourceType GetResourceType(string publicName, int position) diff --git a/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs index 23fd428bf5..60309e3eff 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs @@ -10,6 +10,7 @@ public enum TokenKind Comma, Colon, Minus, + Underscore, Text, QuotedText } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index adc4ad2b09..e98132d1a0 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -2,6 +2,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries; @@ -15,10 +16,89 @@ public sealed class QueryLayer public ResourceType ResourceType { get; } + /// + /// Gets or sets the relationships to include. This only takes affect at the topmost layer. + /// + /// The distinction between null and is significant: + /// + /// + /// + /// null + /// + /// + /// Omits all includes, bypassing eager loading completely (no annotations are loaded). Used for internal count + /// queries, ID-existence checks, and relationship endpoints where full entity graphs are not needed. + /// + /// + /// + /// + /// + /// + /// + /// Indicates not to include any related resources, except for annotations on the root resource type, which will still + /// be loaded. Used for standard queries when no + /// + /// ?include= + /// + /// parameter is specified. + /// + /// + /// + /// + /// Non-empty + /// + /// + /// Includes the specified relationship paths, as well as any annotations on the root resource and along the included + /// relationship chains. + /// + /// + /// + /// + /// public IncludeExpression? Include { get; set; } + public FilterExpression? Filter { get; set; } public SortExpression? Sort { get; set; } public PaginationExpression? Pagination { get; set; } + + /// + /// Gets or sets the field projection (sparse fieldsets and relationship sub-layers) to apply. + /// + /// The distinction between null and an empty (where is true) is: + /// + /// + /// + /// null + /// + /// + /// Bypasses database-level field projection entirely; no + /// + /// .Select(...) + /// + /// clause is applied to the query. The data store materializes the entire entity with all mapped properties. Used when no sparse fieldset is requested + /// or when the full database row is required (e.g. ). + /// + /// + /// + /// + /// Empty + /// + /// + /// Applies a database projection that selects all scalar properties on the entity type (along with any annotations), + /// without restricting attributes or embedding nested relationship sub-layers. + /// + /// + /// + /// + /// Non-empty + /// + /// + /// Restricts database projection to only the selected attributes and/or relationships. Unselected attributes are omitted from the database query result. + /// + /// + /// + /// + /// public FieldSelection? Selection { get; set; } public QueryLayer(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index f058862bcd..c4f95d0b4a 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -20,11 +20,11 @@ public class QueryLayerComposer : IQueryLayerComposer private readonly IPaginationContext _paginationContext; private readonly ITargetedFields _targetedFields; private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly ISparseFieldSetCache _sparseFieldSetCache; + private readonly ISparseFieldSetEvaluator _sparseFieldSetEvaluator; public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, - ISparseFieldSetCache sparseFieldSetCache) + ISparseFieldSetEvaluator sparseFieldSetEvaluator) { ArgumentNullException.ThrowIfNull(constraintProviders); ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); @@ -32,7 +32,7 @@ public QueryLayerComposer(IEnumerable constraintProvid ArgumentNullException.ThrowIfNull(paginationContext); ArgumentNullException.ThrowIfNull(targetedFields); ArgumentNullException.ThrowIfNull(evaluatedIncludeCache); - ArgumentNullException.ThrowIfNull(sparseFieldSetCache); + ArgumentNullException.ThrowIfNull(sparseFieldSetEvaluator); _constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); _resourceDefinitionAccessor = resourceDefinitionAccessor; @@ -40,30 +40,7 @@ public QueryLayerComposer(IEnumerable constraintProvid _paginationContext = paginationContext; _targetedFields = targetedFields; _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = sparseFieldSetCache; - } - - /// - [Obsolete("This method is no longer used and will be removed in a future version.")] - public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) - { - ArgumentNullException.ThrowIfNull(primaryResourceType); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true - - ReadOnlyCollection filtersInTopScope = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray() - .AsReadOnly(); - - // @formatter:wrap_before_first_method_call restore - // @formatter:wrap_chained_method_calls restore - - return GetFilter(filtersInTopScope, primaryResourceType); + _sparseFieldSetEvaluator = sparseFieldSetEvaluator; } /// @@ -85,21 +62,10 @@ public QueryLayerComposer(IEnumerable constraintProvid return null; } - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true - - ReadOnlyCollection filtersInSecondaryScope = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray() - .AsReadOnly(); - - // @formatter:wrap_before_first_method_call restore - // @formatter:wrap_chained_method_calls restore + ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())]; + ReadOnlyCollection expressionsInSecondaryScope = GetExpressionsInTopScope(constraints); - FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); + FilterExpression? primaryFilter = GetFilterFromResourceDefinition(hasManyRelationship.LeftType); if (primaryFilter != null && inverseRelationship is HasOneAttribute) { @@ -108,7 +74,7 @@ public QueryLayerComposer(IEnumerable constraintProvid return null; } - FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); + FilterExpression? secondaryFilter = GetFilter(expressionsInSecondaryScope, hasManyRelationship.RightType); FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, primaryFilter, hasManyRelationship, inverseRelationship); return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, secondaryFilter); @@ -134,11 +100,7 @@ private static ComparisonExpression GetInverseHasOneRelationshipFilter([Dis private static HasExpression GetInverseHasManyRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter, HasManyAttribute relationship, HasManyAttribute inverseRelationship) { - AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); - var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); - var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId)); - - FilterExpression filter = LogicalExpression.Compose(LogicalOperator.And, idComparison, primaryFilter)!; + FilterExpression filter = CreateFilterForIds(relationship.LeftType, primaryFilter, primaryId)!; return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), filter); } @@ -152,8 +114,6 @@ public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); topLayer.Include = ComposeChildren(topLayer, constraints); - _evaluatedIncludeCache.Set(topLayer.Include); - return topLayer; } @@ -161,36 +121,54 @@ private QueryLayer ComposeTopLayer(ImmutableArray constraints { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); + ReadOnlyCollection expressionsInTopScope = GetExpressionsInTopScope(constraints); + + PaginationExpression topPagination = GetTopPagination(expressionsInTopScope, resourceType); + + var topLayer = new QueryLayer(resourceType) + { + Filter = GetFilter(expressionsInTopScope, resourceType), + Sort = GetSort(expressionsInTopScope, resourceType), + Pagination = topPagination, + Selection = GetSelectionForSparseAttributeSet(resourceType) + }; + + EnsureDeterministicSort(topLayer); + + return topLayer; + } + + private static ReadOnlyCollection GetExpressionsInTopScope(ImmutableArray constraints) + { // @formatter:wrap_chained_method_calls chop_always // @formatter:wrap_before_first_method_call true - ReadOnlyCollection expressionsInTopScope = constraints - .Where(constraint => constraint.Scope == null) + return constraints + .Where(constraint => constraint is not RelationshipChainExpressionInScope chainScope || chainScope.Scope == null) .Select(constraint => constraint.Expression) .ToArray() .AsReadOnly(); // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore + } + private PaginationExpression GetTopPagination(IReadOnlyCollection expressionsInTopScope, ResourceType resourceType) + { PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); + _paginationContext.PageSize = topPagination.PageSize; _paginationContext.PageNumber = topPagination.PageNumber; - var topLayer = new QueryLayer(resourceType) - { - Filter = GetFilter(expressionsInTopScope, resourceType), - Sort = GetSort(expressionsInTopScope, resourceType), - Pagination = topPagination, - Selection = GetSelectionForSparseAttributeSet(resourceType) - }; + return topPagination; + } - if (topLayer is { Pagination.PageSize: not null, Sort: null }) + private void EnsureDeterministicSort(QueryLayer layer) + { + if (layer is { Pagination.PageSize: not null, Sort: null }) { - topLayer.Sort = CreateSortById(resourceType); + layer.Sort = CreateSortById(layer.ResourceType); } - - return topLayer; } private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray constraints) @@ -201,6 +179,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray() .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() @@ -212,9 +191,11 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray includeElements = ProcessIncludeSet(include.Elements, topLayer, ImmutableArray.Empty, constraints); - return !ReferenceEquals(includeElements, include.Elements) - ? includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty - : include; + IncludeExpression result = !ReferenceEquals(includeElements, include.Elements) ? IncludeExpression.Create(includeElements) : include; + + _evaluatedIncludeCache.Set(result); + + return result; } private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, @@ -237,6 +218,7 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< // @formatter:wrap_before_first_method_call true ReadOnlyCollection expressionsInCurrentScope = constraints + .OfType() .Where(constraint => constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) .Select(constraint => constraint.Expression) .ToArray() @@ -318,25 +300,26 @@ public QueryLayer ComposeForGetById([DisallowNull] TId id, ResourceType pri ArgumentNullException.ThrowIfNull(id); ArgumentNullException.ThrowIfNull(primaryResourceType); - AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); - - QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); - queryLayer.Sort = null; - queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds([id], idAttribute, queryLayer.Filter); + FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType); if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - queryLayer.Selection = new FieldSelection(); - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); - selectors.IncludeAttribute(idAttribute); + return new QueryLayer(primaryResourceType) + { + Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id), + Selection = CreateSelectionForOnlyId(primaryResourceType) + }; } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null) + + ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())]; + + var queryLayer = new QueryLayer(primaryResourceType) { - // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); - selectors.RemoveAttributes(); - } + Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id), + Selection = fieldSelection == TopFieldSelection.WithAllAttributes ? null : GetSelectionForSparseAttributeSet(primaryResourceType) + }; + + queryLayer.Include = ComposeChildren(queryLayer, constraints); return queryLayer; } @@ -346,20 +329,29 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryRes { ArgumentNullException.ThrowIfNull(secondaryResourceType); - QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); - secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType); - secondaryLayer.Include = null; + ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())]; + ReadOnlyCollection expressionsInTopScope = GetExpressionsInTopScope(constraints); + + var secondaryLayer = new QueryLayer(secondaryResourceType) + { + Filter = GetFilter(expressionsInTopScope, secondaryResourceType), + Sort = GetSort(expressionsInTopScope, secondaryResourceType), + Pagination = GetTopPagination(expressionsInTopScope, secondaryResourceType), + Selection = CreateSelectionForOnlyId(secondaryResourceType) + }; + + EnsureDeterministicSort(secondaryLayer); return secondaryLayer; } - private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType) + private static FieldSelection CreateSelectionForOnlyId(ResourceType resourceType) { var selection = new FieldSelection(); - FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType); + FieldSelectors selectors = selection.GetOrCreateSelectors(resourceType); - IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); - selectors.IncludeAttributes(secondaryAttributeSet); + AttrAttribute idAttribute = GetIdAttribute(resourceType); + selectors.IncludeAttribute(idAttribute); return selection; } @@ -381,21 +373,17 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, secondaryLayer.Sort = null; } - var primarySelection = new FieldSelection(); + FieldSelection primarySelection = CreateSelectionForOnlyId(primaryResourceType); FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); - - IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - primarySelectors.IncludeAttributes(primaryAttributeSet); primarySelectors.IncludeRelationship(relationship, secondaryLayer); - FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType); var primaryLayer = new QueryLayer(primaryResourceType) { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), - Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter), - Selection = primarySelection + Filter = CreateFilterForIds(primaryResourceType, primaryFilter, primaryId), + Selection = primarySelection, + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship) }; if (relationship is HasManyAttribute { DisablePagination: true } && secondaryLayer.Pagination != null) @@ -413,7 +401,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, return primaryLayer; } - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) + private static IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) { IncludeElementExpression parentElement = relativeInclude != null ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) @@ -422,24 +410,36 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? return new IncludeExpression(ImmutableHashSet.Create(parentElement)); } - private FilterExpression? CreateFilterByIds(TId[] ids, AttrAttribute idAttribute, FilterExpression? existingFilter) + private static FilterExpression? CreateFilterForIds(ResourceType resourceType, FilterExpression? existingFilter, params ReadOnlySpan ids) { + if (ids.Length == 0) + { + return existingFilter; + } + + AttrAttribute idAttribute = GetIdAttribute(resourceType); var idChain = new ResourceFieldChainExpression(idAttribute); - FilterExpression? filter = null; + FilterExpression innerFilter; if (ids.Length == 1) { - var constant = new LiteralConstantExpression(ids.Single()!); - filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + var constant = new LiteralConstantExpression(ids[0]!); + innerFilter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } - else if (ids.Length > 1) + else { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet(); - filter = new AnyExpression(idChain, constants); + ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + + foreach (TId id in ids) + { + constantsBuilder.Add(new LiteralConstantExpression(id!)); + } + + innerFilter = new AnyExpression(idChain, constantsBuilder.ToImmutable()); } - return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); + return LogicalExpression.Compose(LogicalOperator.And, innerFilter, existingFilter); } /// @@ -451,16 +451,13 @@ public QueryLayer ComposeForUpdate([DisallowNull] TId id, ResourceType prim ImmutableHashSet includeElements = _targetedFields.Relationships .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType); - QueryLayer primaryLayer = ComposeTopLayer(ImmutableArray.Empty, primaryResourceType); - primaryLayer.Include = includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty; - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds([id], primaryIdAttribute, primaryLayer.Filter); - primaryLayer.Selection = null; - - return primaryLayer; + return new QueryLayer(primaryResourceType) + { + Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id), + Include = IncludeExpression.Create(includeElements) + }; } /// @@ -487,22 +484,15 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati ArgumentNullException.ThrowIfNull(relationship); ArgumentNullException.ThrowIfNull(rightResourceIds); - AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); - object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); - FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); - - var selection = new FieldSelection(); - FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType); - selectors.IncludeAttribute(rightIdAttribute); + FilterExpression? baseFilter = GetFilterFromResourceDefinition(relationship.RightType); + FilterExpression? filter = CreateFilterForIds(relationship.RightType, baseFilter, typedIds); return new QueryLayer(relationship.RightType) { - Include = IncludeExpression.Empty, Filter = filter, - Selection = selection + Selection = CreateSelectionForOnlyId(relationship.RightType) }; } @@ -513,33 +503,26 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, [ ArgumentNullException.ThrowIfNull(leftId); ArgumentNullException.ThrowIfNull(rightResourceIds); - AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); - AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression? leftFilter = CreateFilterByIds([leftId], leftIdAttribute, null); - FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); - - var secondarySelection = new FieldSelection(); - FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType); - secondarySelectors.IncludeAttribute(rightIdAttribute); + FilterExpression? leftFilter = CreateFilterForIds(hasManyRelationship.LeftType, null, leftId); + FilterExpression? rightFilter = CreateFilterForIds(hasManyRelationship.RightType, null, rightTypedIds); QueryLayer secondaryLayer = new(hasManyRelationship.RightType) { Filter = rightFilter, - Selection = secondarySelection + Selection = CreateSelectionForOnlyId(hasManyRelationship.RightType) }; - var primarySelection = new FieldSelection(); + FieldSelection primarySelection = CreateSelectionForOnlyId(hasManyRelationship.LeftType); FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType); primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer); - primarySelectors.IncludeAttribute(leftIdAttribute); return new QueryLayer(hasManyRelationship.LeftType) { - Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), Filter = leftFilter, - Selection = primarySelection + Selection = primarySelection, + Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))) }; } @@ -552,6 +535,11 @@ protected virtual IImmutableSet GetIncludeElements(IIm return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); } + private FilterExpression? GetFilterFromResourceDefinition(ResourceType resourceType) + { + return GetFilter(Array.Empty(), resourceType); + } + protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) { ArgumentNullException.ThrowIfNull(expressionsInScope); @@ -605,23 +593,36 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); + SparseFieldSetExpression? fieldSetExpression = _sparseFieldSetEvaluator.GetSparseFieldSetForQuery(nextType); - if (fieldSet.Count == 0) + if (fieldSetExpression != null) { - continue; + FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); + PopulateSelectors(selectors, fieldSetExpression.Elements); + + AttrAttribute idAttribute = GetIdAttribute(nextType); + selectors.IncludeAttribute(idAttribute); } + } - HashSet attributeSet = fieldSet.OfType().ToHashSet(); + return selection.IsEmpty ? null : selection; + } - FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); - selectors.IncludeAttributes(attributeSet); + private static void PopulateSelectors(FieldSelectors selectors, IImmutableDictionary elements) + { + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in elements) + { + if (field is AttrAttribute attr) + { + selectors.IncludeAttribute(attr); - AttrAttribute idAttribute = GetIdAttribute(nextType); - selectors.IncludeAttribute(idAttribute); + if (element.Children.Count > 0) + { + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(attr); + PopulateSelectors(childSelectors, element.Children); + } + } } - - return selection.IsEmpty ? null : selection; } private static AttrAttribute GetIdAttribute(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs index e46f1249ab..130c56fa14 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -22,7 +22,7 @@ public override Expression VisitInclude(IncludeExpression expression, QueryClaus // De-duplicate chains coming from derived relationships. HashSet propertyPaths = []; - ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); + ApplyEagerLoads(context.ResourceType!.EagerLoads, null, propertyPaths); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.Instance.GetRelationshipChains(expression)) { diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs index c3b58ee125..9c176e77e8 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -17,9 +17,9 @@ public sealed class QueryClauseBuilderContext public Expression Source { get; } /// - /// The resource type for . + /// The resource type for , or null if not scoped to a resource type (e.g. for primitive or compound collections). /// - public ResourceType ResourceType { get; } + public ResourceType? ResourceType { get; } /// /// The extension type to generate calls on, typically or . @@ -51,11 +51,10 @@ public sealed class QueryClauseBuilderContext /// public object? State { get; } - public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IReadOnlyModel entityModel, + public QueryClauseBuilderContext(Expression source, ResourceType? resourceType, Type extensionType, IReadOnlyModel entityModel, LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state) { ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(resourceType); ArgumentNullException.ThrowIfNull(extensionType); ArgumentNullException.ThrowIfNull(entityModel); ArgumentNullException.ThrowIfNull(lambdaScopeFactory); @@ -73,8 +72,13 @@ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, T State = state; } - private static void AssertSameType(Type sourceType, ResourceType resourceType) + private static void AssertSameType(Type sourceType, ResourceType? resourceType) { + if (resourceType == null) + { + return; + } + Type? sourceElementType = CollectionConverter.Instance.FindCollectionElementType(sourceType); if (sourceElementType != resourceType.ClrType) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index 1a1af59749..c0af1f7ddb 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -135,9 +135,24 @@ protected virtual Expression ApplySelection(Expression source, FieldSelection se ArgumentNullException.ThrowIfNull(resourceType); ArgumentNullException.ThrowIfNull(context); + if (HasUnsupportedComplexTypeProjection(selection, resourceType)) + { + // Workaround for EF Core 10 bug (dotnet/efcore#39322): + // RelationalShapedQueryCompilingExpressionVisitor fails with an InvalidCastException + // when compiling a MemberInit projection on an entity that contains a ComplexProperty. + // Bypassing database-level projection allows EF Core to materialize the entity normally; + // ResponseModelAdapter performs the necessary sparse fieldset pruning in memory. + return source; + } + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); return _selectClauseBuilder.ApplySelect(selection, clauseContext); } + + private static bool HasUnsupportedComplexTypeProjection(FieldSelection selection, ResourceType resourceType) + { + return resourceType.Attributes.Any(attr => attr.IsCompound) || selection.GetResourceTypes().Any(type => type.Attributes.Any(attr => attr.IsCompound)); + } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 1be86e0bf5..1ba0b86933 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -29,7 +29,7 @@ public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuild { ArgumentNullException.ThrowIfNull(selection); - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, false, context); + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType!, false, context); LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); @@ -154,6 +154,12 @@ private static void IncludeAllScalarProperties(Type elementType, Dictionary navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) { @@ -166,7 +172,8 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary(); + + if (childSelectors.ContainsReadOnlyAttribute) + { + // If a read-only attribute is selected within this compound type, its calculated value likely depends on another property, + // so fetch all writable properties of this compound type. + foreach (PropertyInfo property in compoundClrType.GetProperties().Where(p => p.SetMethod != null)) + { + MemberExpression childPropertyAccess = Expression.Property(propertyAccess, property); + AttrAttribute? childAttr = childSelectors.Keys.OfType().FirstOrDefault(a => a.Property == property); + FieldSelectors? grandchildSelectors = childAttr != null ? childSelectors.GetChildSelectors(childAttr) : null; + + Expression childRightHandSide; + + if (grandchildSelectors is { IsEmpty: false } && childAttr is not { IsCollection: true }) + { + childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context); + } + else + { + childRightHandSide = childPropertyAccess; + } + + assignments.Add(Expression.Bind(property, childRightHandSide)); + } + } + else + { + foreach ((ResourceFieldAttribute resourceField, _) in childSelectors) + { + if (resourceField is AttrAttribute childAttr && childAttr.Property.SetMethod != null) + { + MemberExpression childPropertyAccess = Expression.Property(propertyAccess, childAttr.Property); + FieldSelectors? grandchildSelectors = childSelectors.GetChildSelectors(childAttr); + + Expression childRightHandSide; + + if (grandchildSelectors is { IsEmpty: false } && !childAttr.IsCollection) + { + childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context); + } + else + { + childRightHandSide = childPropertyAccess; + } + + assignments.Add(Expression.Bind(childAttr.Property, childRightHandSide)); + } + } + } - return Expression.Bind(propertySelector.Property, assignmentRightHandSide); + NewExpression createInstance = _resourceFactory.CreateNewExpression(compoundClrType); + MemberInitExpression memberInit = Expression.MemberInit(createInstance, assignments); + return TestForNull(propertyAccess, memberInit); } private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, MemberExpression propertyAccess, PropertyInfo selectorPropertyInfo, @@ -227,7 +303,10 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Membe return CreateCollectionInitializer(selectorPropertyInfo, bodyElementType, layer, context); } - if (layer.Selection == null || layer.Selection.IsEmpty) + // Direct projection of entities containing compound attributes or collections is bypassed by EF Core queryable building + // due to an EF Core 10 complex type projection limitation (see https://github.com/dotnet/efcore/issues/39322). + if (layer.Selection == null || layer.Selection.IsEmpty || layer.ResourceType.Attributes.Any(attr => attr.IsCompound) || + layer.Selection.GetResourceTypes().Any(type => type.Attributes.Any(attr => attr.IsCompound))) { return propertyAccess; } @@ -277,13 +356,15 @@ private sealed class PropertySelector { public PropertyInfo Property { get; } public QueryLayer? NextLayer { get; } + public FieldSelectors? ChildSelectors { get; } - public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) + public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null, FieldSelectors? childSelectors = null) { ArgumentNullException.ThrowIfNull(property); Property = property; NextLayer = nextLayer; + ChildSelectors = childSelectors; } public override string ToString() diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index 0000456cc5..c82b4fedbc 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -50,11 +50,11 @@ public override Expression VisitHas(HasExpression expression, QueryClauseBuilder if (expression.Filter != null) { - ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType; + ResourceType? nestedResourceType = expression.TargetCollection.Fields[^1] is HasManyAttribute hasMany ? hasMany.RightType : null; using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); - var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + var nestedContext = new QueryClauseBuilderContext(property, nestedResourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); predicate = GetPredicateLambda(expression.Filter, nestedContext); @@ -216,6 +216,11 @@ private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderCont return typeof(int); } + if (expression is ScalarCollectionElementValueExpression elementValue) + { + return elementValue.ElementType; + } + if (expression is ResourceFieldChainExpression chain) { Expression child = Visit(chain, context); @@ -246,4 +251,9 @@ public override Expression VisitLiteralConstant(LiteralConstantExpression expres { return SystemExpressionBuilder.CloseOver(expression.TypedValue); } + + public override Expression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, QueryClauseBuilderContext context) + { + return context.LambdaScope.Accessor; + } } diff --git a/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs new file mode 100644 index 0000000000..c215f5cb3b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries; + +/// +/// The expression that represents a parsed filter, sort, page, include or handler query string parameter, along with the +/// relationship chain to which it applies. A null chain indicates the request's resource scope. +/// +[PublicAPI] +public sealed class RelationshipChainExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) + : ExpressionInScope(expression) +{ + public ResourceFieldChainExpression? Scope { get; } = scope; + + public override string ToString() + { + return $"{Scope} => {Expression}"; + } +} diff --git a/src/JsonApiDotNetCore/Queries/ResourceTypeExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ResourceTypeExpressionInScope.cs new file mode 100644 index 0000000000..b1d10763d6 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/ResourceTypeExpressionInScope.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries; + +/// +/// The expression that represents a parsed fields query string parameter, along with the resource type to which it applies. +/// +[PublicAPI] +public sealed class ResourceTypeExpressionInScope : ExpressionInScope +{ + public ResourceType ResourceType { get; } + + public ResourceTypeExpressionInScope(ResourceType resourceType, QueryExpression expression) + : base(expression) + { + ArgumentNullException.ThrowIfNull(resourceType); + + ResourceType = resourceType; + } + + public override string ToString() + { + return $"{ResourceType} => {Expression}"; + } +} diff --git a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs deleted file mode 100644 index f2c5593ab1..0000000000 --- a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries; - -/// -public sealed class SparseFieldSetCache : ISparseFieldSetCache -{ - private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); - - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy>> _lazySourceTable; - private readonly Dictionary> _visitedTable = []; - - public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentNullException.ThrowIfNull(constraintProviders); - ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); - - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); - } - - private static Dictionary> BuildSourceTable( - IEnumerable constraintProviders) - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true - - KeyValuePair[] sparseFieldTables = constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .Select(expression => expression.Table) - .SelectMany(table => table) - .ToArray(); - - // @formatter:wrap_before_first_method_call restore - // @formatter:wrap_chained_method_calls restore - - var mergedTable = new Dictionary.Builder>(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) - { - if (!mergedTable.TryGetValue(resourceType, out ImmutableHashSet.Builder? builder)) - { - builder = ImmutableHashSet.CreateBuilder(); - mergedTable[resourceType] = builder; - } - - AddSparseFieldsToSet(sparseFieldSet.Fields, builder); - } - - return mergedTable.ToDictionary(pair => pair.Key, pair => pair.Value.ToImmutable()); - } - - private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, - ImmutableHashSet.Builder sparseFieldSetBuilder) - { - foreach (ResourceFieldAttribute field in sparseFieldsToAdd) - { - sparseFieldSetBuilder.Add(field); - } - } - - /// - public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) - { - ArgumentNullException.ThrowIfNull(resourceType); - - if (!_visitedTable.TryGetValue(resourceType, out IImmutableSet? outputFields)) - { - SparseFieldSetExpression? inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out ImmutableHashSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : null; - - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - outputFields = outputExpression == null ? ImmutableHashSet.Empty : outputExpression.Fields; - - _visitedTable[resourceType] = outputFields; - } - - return outputFields; - } - - /// - public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) - { - ArgumentNullException.ThrowIfNull(resourceType); - - AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable<>.Id)); - var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); - - // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - ImmutableHashSet outputAttributes = outputExpression == null - ? ImmutableHashSet.Empty - : outputExpression.Fields.OfType().ToImmutableHashSet(); - - outputAttributes = outputAttributes.Add(idAttribute); - return outputAttributes; - } - - /// - public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) - { - ArgumentNullException.ThrowIfNull(resourceType); - - if (!_visitedTable.TryGetValue(resourceType, out IImmutableSet? outputFields)) - { - SparseFieldSetExpression inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out ImmutableHashSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : GetCachedViewableFieldSet(resourceType); - - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - outputFields = outputExpression == null - ? GetCachedViewableFieldSet(resourceType).Fields - : inputExpression.Fields.Intersect(outputExpression.Fields); - - _visitedTable[resourceType] = outputFields; - } - - return outputFields; - } - - private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) - { - if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) - { - ImmutableHashSet viewableFields = GetViewableFields(resourceType); - fieldSet = new SparseFieldSetExpression(viewableFields); - ViewableFieldSetCache[resourceType] = fieldSet; - } - - return fieldSet; - } - - private static ImmutableHashSet GetViewableFields(ResourceType resourceType) - { - return resourceType.Fields.Where(nextField => !nextField.IsViewBlocked()).ToImmutableHashSet(); - } - - public void Reset() - { - _visitedTable.Clear(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/SparseFieldSetEvaluator.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetEvaluator.cs new file mode 100644 index 0000000000..9eb6a2d465 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetEvaluator.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +public sealed class SparseFieldSetEvaluator : ISparseFieldSetEvaluator +{ + private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); + + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly Lazy> _lazySourceTable; + private readonly Dictionary _visitedQueryTable = []; + private readonly Dictionary _visitedSerializerTable = []; + + public SparseFieldSetEvaluator(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + ArgumentNullException.ThrowIfNull(constraintProviders); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _lazySourceTable = new Lazy>(() => BuildSourceTable(constraintProviders)); + } + + private static Dictionary BuildSourceTable(IEnumerable constraintProviders) + { + var mergedTable = new Dictionary(); + + foreach (ResourceTypeExpressionInScope constraint in constraintProviders.SelectMany(provider => provider.GetConstraints()) + .OfType()) + { + if (constraint.Expression is SparseFieldSetExpression sparseFieldSet) + { + if (mergedTable.TryGetValue(constraint.ResourceType, out SparseFieldSetExpression? existingSet)) + { + mergedTable[constraint.ResourceType] = UnionExpressions(existingSet, sparseFieldSet); + } + else + { + mergedTable[constraint.ResourceType] = sparseFieldSet; + } + } + } + + return mergedTable; + } + + private static SparseFieldSetExpression UnionExpressions(SparseFieldSetExpression first, SparseFieldSetExpression second) + { + ImmutableDictionary mergedElements = UnionElements(first.Elements, second.Elements); + + return SparseFieldSetExpression.Create(mergedElements); + } + + private static ImmutableDictionary UnionElements( + IImmutableDictionary first, + IImmutableDictionary second) + { + var merged = new Dictionary(first); + + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in second) + { + if (merged.TryGetValue(field, out SparseFieldSetElementExpression? existingElement)) + { + if (existingElement.Children.Count == 0 || element.Children.Count == 0) + { + // One selects all children of this field, so the union selects all children. + merged[field] = new SparseFieldSetElementExpression(field); + } + else + { + merged[field] = new SparseFieldSetElementExpression(field, UnionElements(existingElement.Children, element.Children)); + } + } + else + { + merged[field] = element; + } + } + + return merged.ToImmutableDictionary(); + } + + /// + public SparseFieldSetExpression? GetSparseFieldSetForQuery(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (!_visitedQueryTable.TryGetValue(resourceType, out SparseFieldSetExpression? outputExpression)) + { + SparseFieldSetExpression? inputExpression = _lazySourceTable.Value.GetValueOrDefault(resourceType); + + outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + _visitedQueryTable[resourceType] = outputExpression; + } + + return outputExpression; + } + + /// + public SparseFieldSetExpression GetSparseFieldSetForSerializer(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (!_visitedSerializerTable.TryGetValue(resourceType, out SparseFieldSetExpression? outputExpression)) + { + SparseFieldSetExpression inputExpression = _lazySourceTable.Value.TryGetValue(resourceType, out SparseFieldSetExpression? inputFields) + ? inputFields + : GetCachedViewableFieldSet(resourceType); + + SparseFieldSetExpression? callbackExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + + outputExpression = callbackExpression == null ? GetCachedViewableFieldSet(resourceType) : IntersectExpressions(inputExpression, callbackExpression); + + _visitedSerializerTable[resourceType] = outputExpression; + } + + return outputExpression; + } + + private static SparseFieldSetExpression IntersectExpressions(SparseFieldSetExpression first, SparseFieldSetExpression second) + { + ImmutableDictionary intersectedElements = IntersectElements(first.Elements, second.Elements); + + return SparseFieldSetExpression.Create(intersectedElements); + } + + private static ImmutableDictionary IntersectElements( + IImmutableDictionary first, + IImmutableDictionary second) + { + ImmutableDictionary.Builder builder = + ImmutableDictionary.CreateBuilder(); + + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression secondElement) in second) + { + if (first.TryGetValue(field, out SparseFieldSetElementExpression? firstElement)) + { + if (firstElement.Children.Count == 0) + { + // first wanted all children -> restricted to second's children + builder.Add(field, secondElement); + } + else if (secondElement.Children.Count == 0) + { + // second allows all children -> restricted to first's children + builder.Add(field, firstElement); + } + else + { + ImmutableDictionary childIntersection = + IntersectElements(firstElement.Children, secondElement.Children); + + if (childIntersection.Count > 0) + { + builder.Add(field, new SparseFieldSetElementExpression(field, childIntersection)); + } + } + } + } + + return builder.ToImmutable(); + } + + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) + { + return ViewableFieldSetCache.GetOrAdd(resourceType, static type => + { + ImmutableDictionary viewableFields = GetViewableFields(type); + return new SparseFieldSetExpression(viewableFields); + }); + } + + private static ImmutableDictionary GetViewableFields(ResourceType resourceType) + { + return resourceType.Fields.Where(nextField => !nextField.IsViewBlocked()) + .ToImmutableDictionary(field => field, field => new SparseFieldSetElementExpression(field)); + } + + public void Reset() + { + _visitedQueryTable.Clear(); + _visitedSerializerTable.Clear(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 0ad7432005..eb8d47bde4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -5,12 +5,9 @@ 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"); - - [Obsolete("""This is no longer used and will be removed in a future version. Instead, use: FieldChainPattern.Parse("R*M")""")] - public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M"); + public static FieldChainPattern ToOneOrCompoundChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("[OC]*S"); + public static FieldChainPattern ToOneOrCompoundChainEndingInToOneOrAttribute { get; } = FieldChainPattern.Parse("[OC]*[OA]"); + public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MLQ]"); + public static FieldChainPattern CompoundOrCompoundCollectionChainEndingInField { get; } = FieldChainPattern.Parse("[CQ]*F"); } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index e3c15ebdbe..6296c234c0 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -72,6 +72,30 @@ internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, /// /// /// + /// S + /// + /// Matches a scalar non-collection attribute. + /// + /// + /// + /// L + /// + /// Matches a scalar collection attribute. + /// + /// + /// + /// C + /// + /// Matches a compound non-collection attribute. + /// + /// + /// + /// Q + /// + /// Matches a compound collection attribute. + /// + /// + /// /// A /// /// Matches an attribute. @@ -124,13 +148,13 @@ public static FieldChainPattern Parse(string pattern) } /// - /// Matches the specified resource field chain against this pattern. + /// Matches the specified resource field chain against this pattern, starting from the specified container. /// /// /// The dot-separated chain of resource field names. /// - /// - /// The parent resource type to start matching from. + /// + /// The parent attribute container to start matching from. /// /// /// Match options, defaults to . @@ -141,15 +165,15 @@ public static FieldChainPattern Parse(string pattern) /// /// The match result. /// - public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, - ILoggerFactory? loggerFactory = null) + public PatternMatchResult Match(string fieldChain, IAttributeContainer container, + FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(fieldChain); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); ILogger logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); var matcher = new PatternMatcher(this, options, logger); - return matcher.Match(fieldChain, resourceType); + return matcher.Match(fieldChain, container); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs index 8c18c4448e..a21360e0ba 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -6,51 +6,118 @@ internal static class FieldTypeExtensions { public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool pluralize, bool prefix) { - int startOffset = builder.Length; + var writer = new ChoiceWriter(builder, pluralize, prefix); - if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + if (choices.HasFlag(FieldTypes.Field)) { - WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); + writer.Write("field"); } - - if (choices.HasFlag(FieldTypes.ToOneRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + else { - WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); + WriteRelationships(choices, writer); + WriteAttributes(choices, writer); } + } - if (choices.HasFlag(FieldTypes.Attribute) && !choices.HasFlag(FieldTypes.Relationship)) + private static void WriteRelationships(FieldTypes choices, ChoiceWriter writer) + { + if (choices.HasFlag(FieldTypes.Relationship)) { - WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); + writer.Write("relationship"); } - - if (choices.HasFlag(FieldTypes.Relationship) && !choices.HasFlag(FieldTypes.Field)) + else if (choices.HasFlag(FieldTypes.ToOneRelationship)) + { + writer.Write("to-one relationship"); + } + else if (choices.HasFlag(FieldTypes.ToManyRelationship)) { - WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + writer.Write("to-many relationship"); } + } - if (choices.HasFlag(FieldTypes.Field)) + private static void WriteAttributes(FieldTypes choices, ChoiceWriter writer) + { + if (choices.HasFlag(FieldTypes.Attribute)) { - WriteChoice("field", pluralize, prefix, false, builder, startOffset); + writer.Write("attribute"); + } + else + { + bool hasScalar = choices.HasFlag(FieldTypes.ScalarAttribute); + bool hasCompound = choices.HasFlag(FieldTypes.CompoundAttribute); + bool hasScalarCollection = choices.HasFlag(FieldTypes.ScalarCollectionAttribute); + bool hasCompoundCollection = choices.HasFlag(FieldTypes.CompoundCollectionAttribute); + + if (hasScalar && hasCompound && !hasScalarCollection && !hasCompoundCollection) + { + writer.Write("non-collection attribute"); + } + else if (hasScalarCollection && hasCompoundCollection && !hasScalar && !hasCompound) + { + writer.Write("collection attribute"); + } + else + { + WriteScalarAttributes(hasScalar, hasScalarCollection, writer); + WriteCompoundAttributes(hasCompound, hasCompoundCollection, writer); + } } } - private static void WriteChoice(string typeText, bool pluralize, bool prefix, bool isAttribute, StringBuilder builder, int startOffset) + private static void WriteScalarAttributes(bool hasScalar, bool hasScalarCollection, ChoiceWriter writer) { - if (builder.Length > startOffset) + if (hasScalar && hasScalarCollection) + { + writer.Write("scalar attribute"); + } + else if (hasScalar) + { + writer.Write("scalar non-collection attribute"); + } + else if (hasScalarCollection) { - builder.Append(" or "); + writer.Write("scalar collection attribute"); } + } - if (prefix && !pluralize) + private static void WriteCompoundAttributes(bool hasCompound, bool hasCompoundCollection, ChoiceWriter writer) + { + if (hasCompound && hasCompoundCollection) + { + writer.Write("compound attribute"); + } + else if (hasCompound) { - builder.Append(isAttribute ? "an " : "a "); + writer.Write("compound non-collection attribute"); } + else if (hasCompoundCollection) + { + writer.Write("compound collection attribute"); + } + } - builder.Append(typeText); + private readonly ref struct ChoiceWriter(StringBuilder builder, bool pluralize, bool prefix) + { + private readonly int _startOffset = builder.Length; - if (pluralize) + public void Write(string choice) { - builder.Append('s'); + if (builder.Length > _startOffset) + { + builder.Append(" or "); + } + + if (prefix && !pluralize) + { + builder.Append(choice.StartsWith('a') ? "an " : "a "); + } + + builder.Append(choice); + + if (pluralize) + { + builder.Append('s'); + } } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs index 8011ec3ec4..d4105f6e6b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -4,9 +4,14 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; internal enum FieldTypes { None = 0, - Attribute = 1, - ToOneRelationship = 1 << 1, - ToManyRelationship = 1 << 2, + ToOneRelationship = 1, + ToManyRelationship = 1 << 1, + ScalarAttribute = 1 << 2, + ScalarCollectionAttribute = 1 << 3, + CompoundAttribute = 1 << 4, + CompoundCollectionAttribute = 1 << 5, + Relationship = ToOneRelationship | ToManyRelationship, - Field = Attribute | Relationship + Attribute = ScalarAttribute | ScalarCollectionAttribute | CompoundAttribute | CompoundCollectionAttribute, + Field = Relationship | Attribute } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs index 3707151560..045271c1e7 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -35,14 +35,14 @@ public static MatchError CreateForBrokenFieldChain(FieldChainFormatException exc return new MatchError(exception.Message, exception.Position, true); } - public static MatchError CreateForUnknownField(int position, ResourceType? resourceType, string publicName, bool allowDerivedTypes) + public static MatchError CreateForUnknownField(int position, IAttributeContainer? container, string publicName, bool allowDerivedTypes) { - bool hasDerivedTypes = allowDerivedTypes && resourceType is { DirectlyDerivedTypes.Count: > 0 }; + bool hasDerivedTypes = allowDerivedTypes && container is ResourceType { DirectlyDerivedTypes.Count: > 0 }; var builder = new MessageBuilder(); builder.WriteDoesNotExist(publicName); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); builder.WriteOrDerivedTypes(hasDerivedTypes); builder.WriteEnd(); @@ -56,12 +56,17 @@ public static MatchError CreateForMultipleDerivedTypes(int position, ResourceTyp return new MatchError(message, position, true); } - public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? resourceType, FieldTypes choices) + public static MatchError CreateForFieldTypeMismatch(int position, IAttributeContainer? container, FieldTypes choices) { + if (container is CompoundType) + { + choices &= ~FieldTypes.Relationship; + } + var builder = new MessageBuilder(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); builder.WriteExpected(); builder.WriteEnd(); @@ -69,8 +74,13 @@ public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? return new MatchError(message, position, false); } - public static MatchError CreateForTooMuchInput(int position, ResourceType? resourceType, FieldTypes choices) + public static MatchError CreateForTooMuchInput(int position, IAttributeContainer? container, FieldTypes choices) { + if (container is CompoundType) + { + choices &= ~FieldTypes.Relationship; + } + var builder = new MessageBuilder(); builder.WriteEndOfChain(); @@ -79,7 +89,7 @@ public static MatchError CreateForTooMuchInput(int position, ResourceType? resou { builder.WriteOr(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); } builder.WriteExpected(); @@ -132,12 +142,16 @@ public void WriteChoices(FieldTypes choices) } } - public void WriteResourceType(ResourceType? resourceType) + public void WriteContainer(IAttributeContainer? container) { - if (resourceType != null) + if (container is ResourceType resourceType) { _builder.Append($" on resource type '{resourceType}'"); } + else if (container is CompoundType compoundType) + { + _builder.Append($" on compound type '{compoundType}'"); + } } public void WriteExpected() diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index 678850e4c4..f7381e2e23 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -15,15 +15,20 @@ internal sealed class MatchState /// private readonly MatchState? _parentMatch; + /// + /// The resource type to find the next field on. + /// + private ResourceType? ResourceType => Container as ResourceType; + /// /// 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. + /// The container to find the next field on. /// - public ResourceType? ResourceType { get; } + public IAttributeContainer? Container { get; } /// /// The fields matched against this pattern segment. @@ -40,22 +45,22 @@ internal sealed class MatchState /// public MatchError? Error { get; } - private MatchState(FieldChainPattern? pattern, ResourceType? resourceType, IImmutableList fieldsMatched, + private MatchState(FieldChainPattern? pattern, IAttributeContainer? container, IImmutableList fieldsMatched, LinkedListNode? fieldsRemaining, MatchError? error, MatchState? parentMatch) { Pattern = pattern; - ResourceType = resourceType; + Container = container; FieldsMatched = fieldsMatched; FieldsRemaining = fieldsRemaining; Error = error; _parentMatch = parentMatch; } - public static MatchState Create(FieldChainPattern pattern, string fieldChainText, ResourceType resourceType) + public static MatchState Create(FieldChainPattern pattern, string fieldChainText, IAttributeContainer container) { ArgumentNullException.ThrowIfNull(pattern); ArgumentNullException.ThrowIfNull(fieldChainText); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); try { @@ -63,12 +68,12 @@ public static MatchState Create(FieldChainPattern pattern, string fieldChainText IEnumerable fieldChain = parser.Parse(fieldChainText); LinkedListNode? remainingHead = new LinkedList(fieldChain).First; - return new MatchState(pattern, resourceType, ImmutableArray.Empty, remainingHead, null, null); + return new MatchState(pattern, container, ImmutableArray.Empty, remainingHead, null, null); } catch (FieldChainFormatException exception) { var error = MatchError.CreateForBrokenFieldChain(exception); - return new MatchState(pattern, resourceType, ImmutableArray.Empty, null, error, null); + return new MatchState(pattern, container, ImmutableArray.Empty, null, error, null); } } @@ -82,9 +87,24 @@ public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue IImmutableList fieldsMatched = FieldsMatched.Add(matchedValue); LinkedListNode? fieldsRemaining = FieldsRemaining!.Next; - ResourceType? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; + IAttributeContainer? container = GetNextContainer(matchedValue); + + return new MatchState(Pattern, container, fieldsMatched, fieldsRemaining, null, _parentMatch); + } + + private static IAttributeContainer? GetNextContainer(ResourceFieldAttribute matchedValue) + { + if (matchedValue is RelationshipAttribute relationship) + { + return relationship.RightType; + } + + if (matchedValue is AttrAttribute { IsCompound: true } attr) + { + return attr.CompoundType; + } - return new MatchState(Pattern, resourceType, fieldsMatched, fieldsRemaining, null, _parentMatch); + return null; } /// @@ -95,7 +115,7 @@ public MatchState SuccessMoveToNextPattern() AssertIsSuccess(this); AssertHasPattern(); - return new MatchState(Pattern!.Next, ResourceType, ImmutableArray.Empty, FieldsRemaining, null, this); + return new MatchState(Pattern!.Next, Container, ImmutableArray.Empty, FieldsRemaining, null, this); } /// @@ -104,7 +124,7 @@ public MatchState SuccessMoveToNextPattern() public MatchState FailureForUnknownField(string publicName, bool allowDerivedTypes) { int position = GetAbsolutePosition(true); - var error = MatchError.CreateForUnknownField(position, ResourceType, publicName, allowDerivedTypes); + var error = MatchError.CreateForUnknownField(position, Container, publicName, allowDerivedTypes); return Failure(error); } @@ -129,7 +149,7 @@ public MatchState FailureForFieldTypeMismatch(FieldTypes choices, FieldTypes cho { FieldTypes allChoices = IncludeChoicesFromParentMatch(choices); int position = GetAbsolutePosition(chosenFieldType != FieldTypes.None); - var error = MatchError.CreateForFieldTypeMismatch(position, ResourceType, allChoices); + var error = MatchError.CreateForFieldTypeMismatch(position, Container, allChoices); return Failure(error); } @@ -174,14 +194,14 @@ public MatchState FailureForTooMuchInput() { FieldTypes parentChoices = IncludeChoicesFromParentMatch(FieldTypes.None); int position = GetAbsolutePosition(true); - var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.ResourceType, parentChoices); + var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.Container, parentChoices); return Failure(error); } private MatchState Failure(MatchError error) { - return new MatchState(Pattern, ResourceType, FieldsMatched, FieldsRemaining, error, _parentMatch); + return new MatchState(Pattern, Container, FieldsMatched, FieldsRemaining, error, _parentMatch); } private int GetAbsolutePosition(bool hasLeadingDot) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 9a86542533..65fbe62d60 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -23,12 +23,12 @@ public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions o _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); } - public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + public PatternMatchResult Match(string fieldChain, IAttributeContainer container) { ArgumentNullException.ThrowIfNull(fieldChain); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); - var startState = MatchState.Create(_pattern, fieldChain, resourceType); + var startState = MatchState.Create(_pattern, fieldChain, container); if (startState.Error != null) { @@ -155,7 +155,7 @@ private MatchState MatchField(MatchState state) { string publicName = state.FieldsRemaining.Value; - HashSet fields = LookupFields(state.ResourceType, publicName); + HashSet fields = LookupFields(state.Container, publicName); if (fields.Count == 0) { @@ -184,11 +184,11 @@ private MatchState MatchField(MatchState state) /// /// Lookup the specified field in the resource graph. /// - private HashSet LookupFields(ResourceType? resourceType, string publicName) + private HashSet LookupFields(IAttributeContainer? container, string publicName) { HashSet fields = []; - if (resourceType != null) + if (container is ResourceType resourceType) { if (_allowDerivedTypes) { @@ -215,6 +215,15 @@ private HashSet LookupFields(ResourceType? resourceType, } } } + else + { + AttrAttribute? attribute = container?.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + } return fields; } @@ -233,7 +242,10 @@ private static FieldTypes GetFieldType(ResourceFieldAttribute? field) HasManyAttribute => FieldTypes.ToManyRelationship, HasOneAttribute => FieldTypes.ToOneRelationship, RelationshipAttribute => FieldTypes.Relationship, - AttrAttribute => FieldTypes.Attribute, + AttrAttribute { IsCompound: true, IsCollection: true } => FieldTypes.CompoundCollectionAttribute, + AttrAttribute { IsCompound: true, IsCollection: false } => FieldTypes.CompoundAttribute, + AttrAttribute { IsCompound: false, IsCollection: true } => FieldTypes.ScalarCollectionAttribute, + AttrAttribute => FieldTypes.ScalarAttribute, null => FieldTypes.None, _ => FieldTypes.Field }; diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs index c6e05fdd4f..da8024757a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -15,6 +15,10 @@ internal sealed class PatternParser ['M'] = Token.ToManyRelationship, ['O'] = Token.ToOneRelationship, ['R'] = Token.Relationship, + ['S'] = Token.ScalarAttribute, + ['L'] = Token.ScalarCollectionAttribute, + ['C'] = Token.CompoundAttribute, + ['Q'] = Token.CompoundCollectionAttribute, ['A'] = Token.Attribute, ['F'] = Token.Field }; @@ -24,6 +28,10 @@ internal sealed class PatternParser [Token.ToManyRelationship] = FieldTypes.ToManyRelationship, [Token.ToOneRelationship] = FieldTypes.ToOneRelationship, [Token.Relationship] = FieldTypes.Relationship, + [Token.ScalarAttribute] = FieldTypes.ScalarAttribute, + [Token.CompoundAttribute] = FieldTypes.CompoundAttribute, + [Token.ScalarCollectionAttribute] = FieldTypes.ScalarCollectionAttribute, + [Token.CompoundCollectionAttribute] = FieldTypes.CompoundCollectionAttribute, [Token.Attribute] = FieldTypes.Attribute, [Token.Field] = FieldTypes.Field }; @@ -180,6 +188,10 @@ private enum Token ToManyRelationship, ToOneRelationship, Relationship, + ScalarAttribute, + CompoundAttribute, + ScalarCollectionAttribute, + CompoundCollectionAttribute, Attribute, Field } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs index 2fec95b900..aac133ea8f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -35,39 +35,68 @@ public string Format() private static void WriteChoices(FieldTypes types, StringBuilder builder) { - int startOffset = builder.Length; - - if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) + if (types.HasFlag(FieldTypes.Field)) { - builder.Append('M'); + builder.Append('F'); } - - if (types.HasFlag(FieldTypes.ToOneRelationship) && !types.HasFlag(FieldTypes.Relationship)) + else { - builder.Append('O'); - } + int startOffset = builder.Length; - if (types.HasFlag(FieldTypes.Attribute) && !types.HasFlag(FieldTypes.Relationship)) - { - builder.Append('A'); + WriteRelationshipTokens(types, builder); + WriteAttributeTokens(types, builder); + + if (builder.Length - startOffset > 1) + { + builder.Insert(startOffset, '['); + builder.Append(']'); + } } + } - if (types.HasFlag(FieldTypes.Relationship) && !types.HasFlag(FieldTypes.Field)) + private static void WriteRelationshipTokens(FieldTypes types, StringBuilder builder) + { + if (types.HasFlag(FieldTypes.Relationship)) { builder.Append('R'); } - - if (types.HasFlag(FieldTypes.Field)) + else if (types.HasFlag(FieldTypes.ToOneRelationship)) { - builder.Append('F'); + builder.Append('O'); } + else if (types.HasFlag(FieldTypes.ToManyRelationship)) + { + builder.Append('M'); + } + } - int charCount = builder.Length - startOffset; - - if (charCount > 1) + private static void WriteAttributeTokens(FieldTypes types, StringBuilder builder) + { + if (types.HasFlag(FieldTypes.Attribute)) + { + builder.Append('A'); + } + else { - builder.Insert(startOffset, '['); - builder.Append(']'); + if (types.HasFlag(FieldTypes.ScalarAttribute)) + { + builder.Append('S'); + } + + if (types.HasFlag(FieldTypes.ScalarCollectionAttribute)) + { + builder.Append('L'); + } + + if (types.HasFlag(FieldTypes.CompoundAttribute)) + { + builder.Append('C'); + } + + if (types.HasFlag(FieldTypes.CompoundCollectionAttribute)) + { + builder.Append('Q'); + } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index 5c1fffb8b5..b2eff2ece8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -7,6 +7,8 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.QueryStrings; @@ -51,8 +53,7 @@ public virtual bool CanRead(string parameterName) { ArgumentException.ThrowIfNullOrEmpty(parameterName); - bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith(']'); - return parameterName == "filter" || isNested; + return parameterName == "filter" || IsFilterParameterScoped(parameterName); } /// @@ -93,6 +94,7 @@ private void ReadSingleValue(string parameterName, string parameterValue) if (_options.EnableLegacyFilterNotation) { + ValidateLegacyRelationshipFilter(name, value); (name, value) = LegacyConverter.Convert(name, value); } @@ -117,13 +119,40 @@ private void ReadSingleValue(string parameterName, string parameterValue) } } + private void ValidateLegacyRelationshipFilter(string parameterName, string parameterValue) + { + if (IsFilterParameterScoped(parameterName)) + { + string fieldChain = parameterName["filter[".Length..^1]; + + if (fieldChain.Length > 0 && parameterValue != LegacyFilterNotationConverter.ParameterValuePrefix.IsNull && + parameterValue != LegacyFilterNotationConverter.ParameterValuePrefix.IsNotNull) + { + PatternMatchResult result = BuiltInPatterns.ToOneOrCompoundChainEndingInToOneOrAttribute.Match(fieldChain, RequestResourceType); + + if (result is { IsSuccess: true, FieldChain.Count: > 0 } && result.FieldChain[^1] is RelationshipAttribute relationship) + { + throw new QueryParseException( + $"Filtering on relationship '{relationship.PublicName}' is only supported using '{LegacyFilterNotationConverter.ParameterValuePrefix.IsNull}' " + + $"or '{LegacyFilterNotationConverter.ParameterValuePrefix.IsNotNull}'. To filter by related attributes, " + + $"use 'filter[{relationship.PublicName}.id]=123' or use expression syntax.", -1); + } + } + } + } + + private static bool IsFilterParameterScoped(string parameterName) + { + return parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith(']'); + } + private IncludeExpression? GetScope(string parameterName) { QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); if (parameterScope.Scope == null) { - AssertIsCollectionRequest(); + AssertIsCollectionRequest(parameterName); } return parameterScope.Scope; @@ -164,13 +193,13 @@ private IEnumerable EnumerateFiltersInScopes() if (_filtersInGlobalScope.Count > 0) { FilterExpression filter = MergeFilters(_filtersInGlobalScope.ToImmutable()); - yield return new ExpressionInScope(null, filter); + yield return new RelationshipChainExpressionInScope(null, filter); } foreach ((ResourceFieldChainExpression scope, ImmutableArray.Builder filtersBuilder) in _filtersPerScope) { FilterExpression filter = MergeFilters(filtersBuilder.ToImmutable()); - yield return new ExpressionInScope(scope, filter); + yield return new RelationshipChainExpressionInScope(scope, filter); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs index 55ef25779f..5c58fc62a5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -67,8 +67,8 @@ private IncludeExpression GetInclude(string parameterValue) public virtual IReadOnlyCollection GetConstraints() { ExpressionInScope expressionInScope = _includeExpression != null - ? new ExpressionInScope(null, _includeExpression) - : new ExpressionInScope(null, IncludeExpression.Empty); + ? new RelationshipChainExpressionInScope(null, _includeExpression) + : new RelationshipChainExpressionInScope(null, IncludeExpression.Empty); return [expressionInScope]; } diff --git a/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs index eff147e3d4..28f4e3f39b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs @@ -124,7 +124,7 @@ private static string ExtractAttributeName(string parameterName) } } - throw new QueryParseException("Expected field name between brackets in filter parameter name.", -1); + throw new QueryParseException("Field name between brackets expected in filter parameter name.", -1); } private static string EscapeQuotes(string text) @@ -132,7 +132,7 @@ private static string EscapeQuotes(string text) return text.Replace("'", "''"); } - private sealed class ParameterValuePrefix + internal static class ParameterValuePrefix { public const string Equal = "eq:"; public const string NotEqual = "ne:"; diff --git a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index 7f4012478f..8562573063 100644 --- a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -65,7 +65,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) if (constraint.Elements.Any(element => element.Scope == null)) { isParameterNameValid = false; - AssertIsCollectionRequest(); + AssertIsCollectionRequest(parameterName); isParameterNameValid = true; } @@ -102,7 +102,7 @@ protected virtual void ValidatePageSize(PaginationQueryStringValueExpression con { if (element.Value > _options.MaximumPageSize.Value) { - throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}.", element.Position); + throw new QueryParseException($"Page size cannot be greater than {_options.MaximumPageSize}.", element.Position); } if (element.Value == 0) @@ -128,7 +128,7 @@ protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression c { if (element.Value > _options.MaximumPageNumber.OneBasedValue) { - throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}.", element.Position); + throw new QueryParseException($"Page number cannot be greater than {_options.MaximumPageNumber}.", element.Position); } } @@ -225,11 +225,11 @@ public ReadOnlyCollection GetExpressionsInScope() private IEnumerable EnumerateExpressionsInScope() { - yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); + yield return new RelationshipChainExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) { - yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); + yield return new RelationshipChainExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs index e51cb2a5a2..6e845a63fe 100644 --- a/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs @@ -48,11 +48,12 @@ protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression? sco return _resourceGraph.GetResourceType(lastField.Property.PropertyType); } - protected void AssertIsCollectionRequest() + protected void AssertIsCollectionRequest(string parameterName) { if (!_isCollectionRequest) { - throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource).", 0); + string message = $"Query string parameter '{parameterName}' can only be used on a collection of resources."; + throw new QueryParseException(message, 0); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs index 7c03f7ee2e..95bc39b3f4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs @@ -72,13 +72,12 @@ private void ReadSingle(string parameterName, StringValues parameterValue, Disab if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue)) { throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.", - $"Missing value for '{parameterName}' query string parameter."); + $"Parameter '{parameterName}' cannot have an empty value."); } if (!reader.IsEnabled(disableQueryStringAttribute)) { - throw new InvalidQueryStringParameterException(parameterName, - "Usage of one or more query string parameters is not allowed at the requested endpoint.", + throw new InvalidQueryStringParameterException(parameterName, "Usage of query string parameter is not allowed.", $"The parameter '{parameterName}' cannot be used at this endpoint."); } diff --git a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index fb52e5775d..10f4aea97a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -52,7 +52,7 @@ public virtual bool CanRead(string parameterName) public virtual void Read(string parameterName, StringValues parameterValue) { object queryableHandler = GetQueryableHandler(parameterName)!; - var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); + var expressionInScope = new RelationshipChainExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); _constraints.Add(expressionInScope); } diff --git a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 8bfe85a9e0..508b63bfc4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -63,7 +63,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) : IncludeChainConverter.Instance.GetRelationshipChains(scopeInclude)) { SortExpression sort = GetSort(parameterValue.ToString(), scopeChain); - var expressionInScope = new ExpressionInScope(scopeChain, sort); + var expressionInScope = new RelationshipChainExpressionInScope(scopeChain, sort); _constraints.Add(expressionInScope); } } @@ -80,7 +80,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) if (parameterScope.Scope == null) { - AssertIsCollectionRequest(); + AssertIsCollectionRequest(parameterName); } return parameterScope.Scope; diff --git a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index 454fb34c49..61525ce210 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -7,8 +7,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.QueryStrings; @@ -80,23 +78,14 @@ private ResourceType GetScope(string parameterName) private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) { - SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); - - if (sparseFieldSet == null) - { - // 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)); - } - - return sparseFieldSet; + return _sparseFieldSetParser.Parse(parameterValue, resourceType); } /// public virtual IReadOnlyCollection GetConstraints() { return _sparseFieldTableBuilder.Count > 0 - ? [new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable()))] + ? _sparseFieldTableBuilder.Select(pair => new ResourceTypeExpressionInScope(pair.Key, pair.Value)).ToArray().AsReadOnly() : Array.Empty(); } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7de6accc6c..8e0bf90307 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -136,6 +136,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) QueryableHandlerExpression[] queryableHandlers = _constraintProviders .SelectMany(provider => provider.GetConstraints()) + .OfType() .Where(expressionInScope => expressionInScope.Scope == null) .Select(expressionInScope => expressionInScope.Expression) .OfType() diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index c17ce60b45..fb14fc647b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; @@ -56,6 +57,12 @@ public void SetFinallyStoredAttributeValues(TResource resource) foreach (AttrAttribute attribute in attributes) { object? value = attribute.GetValue(resource); + + if (value != null && (attribute.IsCompound || attribute.IsCollection)) + { + value = JsonSerializer.SerializeToElement(value); + } + result.Add(attribute.PublicName, value); } @@ -73,7 +80,7 @@ public bool HasImplicitChanges() { object? actualValue = _finallyStoredAttributeValues[key]; - if (!Equals(requestValue, actualValue)) + if (!AreValuesEqual(requestValue, actualValue)) { return true; } @@ -83,7 +90,7 @@ public bool HasImplicitChanges() object? initiallyStoredValue = _initiallyStoredAttributeValues[key]; object? finallyStoredValue = _finallyStoredAttributeValues[key]; - if (!Equals(initiallyStoredValue, finallyStoredValue)) + if (!AreValuesEqual(initiallyStoredValue, finallyStoredValue)) { return true; } @@ -93,4 +100,14 @@ public bool HasImplicitChanges() return false; } + + private static bool AreValuesEqual(object? left, object? right) + { + if (left is JsonElement leftElement && right is JsonElement rightElement) + { + return JsonElement.DeepEquals(leftElement, rightElement); + } + + return Equals(left, right); + } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index e66550fbac..e2587e5a37 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -208,6 +208,10 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver { attributeValue = JsonInvalidAttributeInfo.Id; } + else if (attribute!.IsCompound) + { + attributeValue = ReadSubTree(ref reader, options); + } else { try diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 2df888846e..b9c3dfaea7 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -67,16 +68,200 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); AssertNotReadOnly(attr, resourceType, state); + if (attr.IsCompound) + { + if (attributeValue is JsonElement jsonElement) + { + attributeValue = attr.IsCollection + ? ConvertCompoundCollection(jsonElement, attr.CompoundType!, attr.Property.PropertyType, attributeName, state) + : ConvertCompoundObject(jsonElement, attr.CompoundType!, attr.Property.PropertyType, attributeName, state); + } + else if (attributeValue != null && !attr.Property.PropertyType.IsInstanceOfType(attributeValue)) + { + JsonElement serializedElement = JsonSerializer.SerializeToElement(attributeValue, _options.SerializerReadOptions); + + attributeValue = attr.IsCollection + ? ConvertCompoundCollection(serializedElement, attr.CompoundType!, attr.Property.PropertyType, attributeName, state) + : ConvertCompoundObject(serializedElement, attr.CompoundType!, attr.Property.PropertyType, attributeName, state); + } + } + else if (attributeValue != null && !attr.Property.PropertyType.IsInstanceOfType(attributeValue)) + { + try + { + if (attributeValue is JsonElement jsonElement) + { + attributeValue = jsonElement.Deserialize(attr.Property.PropertyType, _options.SerializerReadOptions); + } + else if (attr.IsCollection) + { + JsonElement serializedElement = JsonSerializer.SerializeToElement(attributeValue, _options.SerializerReadOptions); + attributeValue = serializedElement.Deserialize(attr.Property.PropertyType, _options.SerializerReadOptions); + } + } + catch (JsonException exception) + { + string typeName = RuntimeTypeConverter.GetFriendlyTypeName(attr.Property.PropertyType); + + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", + $"Failed to convert attribute '{attributeName}' with value '{attributeValue}' to type '{typeName}'.", innerException: exception); + } + } + attr.SetValue(resource, attributeValue); state.WritableTargetedFields!.Attributes.Add(attr); } - private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + private object? ConvertCompoundObject(JsonElement element, CompoundType compoundType, Type targetType, string? memberName, RequestAdapterState state) + { + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + if (element.ValueKind != JsonValueKind.Object) + { + string typeName = RuntimeTypeConverter.GetFriendlyTypeName(targetType); + + string message = memberName != null + ? $"Failed to convert attribute '{memberName}' with value '{element}' of type '{element.ValueKind}' to type '{typeName}'." + : $"Failed to convert element with value '{element}' of type '{element.ValueKind}' to type '{typeName}'."; + + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", message); + } + + object instance = CreateCompoundInstance(targetType); + + foreach (JsonProperty jsonProperty in element.EnumerateObject()) + { + string childMemberName = jsonProperty.Name; + AttrAttribute? childAttr = compoundType.FindAttributeByPublicName(childMemberName); + + if (childAttr == null) + { + if (_options.AllowUnknownFieldsInRequestBody) + { + continue; + } + + using (state.Position.PushElement(childMemberName)) + { + AssertIsKnownAttribute(childAttr, childMemberName, compoundType, state); + } + } + + using (state.Position.PushElement(childAttr.PublicName)) + { + AssertNotReadOnly(childAttr, compoundType, state); + AssertSetAttributeInCreateResourceNotBlocked(childAttr, compoundType, state); + AssertSetAttributeInUpdateResourceNotBlocked(childAttr, compoundType, state); + + object? childValue; + + if (childAttr.IsCompound) + { + childValue = childAttr.IsCollection + ? ConvertCompoundCollection(jsonProperty.Value, childAttr.CompoundType!, childAttr.Property.PropertyType, childAttr.PublicName, state) + : ConvertCompoundObject(jsonProperty.Value, childAttr.CompoundType!, childAttr.Property.PropertyType, childAttr.PublicName, state); + } + else + { + try + { + childValue = jsonProperty.Value.Deserialize(childAttr.Property.PropertyType, _options.SerializerReadOptions); + } + catch (JsonException exception) + { + string typeName = RuntimeTypeConverter.GetFriendlyTypeName(childAttr.Property.PropertyType); + + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", + $"Failed to convert attribute '{childAttr.PublicName}' with value '{jsonProperty.Value}' of type '{jsonProperty.Value.ValueKind}' to type '{typeName}'.", + innerException: exception); + } + } + + childAttr.SetValue(instance, childValue); + } + } + + return instance; + } + + private object? ConvertCompoundCollection(JsonElement element, CompoundType elementCompoundType, Type collectionType, string? memberName, + RequestAdapterState state) + { + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + if (element.ValueKind != JsonValueKind.Array) + { + string typeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionType); + + string message = memberName != null + ? $"Failed to convert attribute '{memberName}' with value '{element}' of type '{element.ValueKind}' to type '{typeName}'." + : $"Failed to convert element with value '{element}' of type '{element.ValueKind}' to type '{typeName}'."; + + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", message); + } + + Type elementType = CollectionConverter.Instance.FindCollectionElementType(collectionType) ?? + (collectionType.IsArray ? collectionType.GetElementType() : null) ?? elementCompoundType.ClrType; + + var items = new List(); + int index = 0; + + foreach (JsonElement itemElement in element.EnumerateArray()) + { + using (state.Position.PushArrayIndex(index)) + { + object? itemValue = itemElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? null + : ConvertCompoundObject(itemElement, elementCompoundType, elementType, null, state); + + items.Add(itemValue); + } + + index++; + } + + if (collectionType.IsArray) + { + var array = Array.CreateInstance(elementType, items.Count); + + for (int i = 0; i < items.Count; i++) + { + array.SetValue(items[i], i); + } + + return array; + } + + return CollectionConverter.Instance.CopyToTypedCollection(items, collectionType); + } + + private static object CreateCompoundInstance(Type type) + { + try + { + return Activator.CreateInstance(type, true) ?? throw new InvalidOperationException($"Failed to instantiate compound type '{type.Name}'."); + } + catch (MissingMethodException exception) + { + throw new InvalidOperationException( + $"Compound type '{type.Name}' must have a parameterless constructor (can be non-public) to support deserialization.", exception); + } + } + + private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, IAttributeContainer container, RequestAdapterState state) { if (attr == null) { + string containerKind = container is ResourceType ? "resource type" : "compound type"; + throw new ModelConversionException(state.Position, "Unknown attribute found.", - $"Attribute '{attributeName}' does not exist on resource type '{resourceType}'."); + $"Attribute '{attributeName}' does not exist on {containerKind} '{container}'."); } } @@ -97,30 +282,34 @@ private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdap } } - private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, IAttributeContainer container, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { + string containerKind = container is ResourceType ? "resource type" : "compound type"; + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when creating resource.", - $"The attribute '{attr}' on resource type '{resourceType}' cannot be assigned to."); + $"The attribute '{attr}' on {containerKind} '{container}' cannot be assigned to."); } } - private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, IAttributeContainer container, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { + string containerKind = container is ResourceType ? "resource type" : "compound type"; + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when updating resource.", - $"The attribute '{attr}' on resource type '{resourceType}' cannot be assigned to."); + $"The attribute '{attr}' on {containerKind} '{container}' cannot be assigned to."); } } - private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertNotReadOnly(AttrAttribute attr, IAttributeContainer container, RequestAdapterState state) { if (attr.Property.SetMethod == null) { - throw new ModelConversionException(state.Position, "Attribute is read-only.", - $"Attribute '{attr}' on resource type '{resourceType}' is read-only."); + string containerKind = container is ResourceType ? "resource type" : "compound type"; + throw new ModelConversionException(state.Position, "Attribute is read-only.", $"Attribute '{attr}' on {containerKind} '{container}' is read-only."); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index c4a530fdd0..b96d2fea76 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Collections.Immutable; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -25,13 +26,13 @@ public class ResponseModelAdapter : IResponseModelAdapter private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; private readonly IRequestQueryStringAccessor _requestQueryStringAccessor; - private readonly ISparseFieldSetCache _sparseFieldSetCache; + private readonly ISparseFieldSetEvaluator _sparseFieldSetEvaluator; // Ensures that at most one ResourceObject (and one tree node) is produced per resource instance. private readonly Dictionary _resourceToTreeNodeCache = new(IdentifiableComparer.Instance); public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, ILinkBuilder linkBuilder, IMetaBuilder metaBuilder, - IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache, + IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetEvaluator sparseFieldSetEvaluator, IRequestQueryStringAccessor requestQueryStringAccessor) { ArgumentNullException.ThrowIfNull(request); @@ -40,7 +41,7 @@ public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, IL ArgumentNullException.ThrowIfNull(metaBuilder); ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); ArgumentNullException.ThrowIfNull(evaluatedIncludeCache); - ArgumentNullException.ThrowIfNull(sparseFieldSetCache); + ArgumentNullException.ThrowIfNull(sparseFieldSetEvaluator); ArgumentNullException.ThrowIfNull(requestQueryStringAccessor); _request = request; @@ -49,14 +50,14 @@ public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, IL _metaBuilder = metaBuilder; _resourceDefinitionAccessor = resourceDefinitionAccessor; _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = sparseFieldSetCache; + _sparseFieldSetEvaluator = sparseFieldSetEvaluator; _requestQueryStringAccessor = requestQueryStringAccessor; } /// public Document Convert(object? model) { - _sparseFieldSetCache.Reset(); + _sparseFieldSetEvaluator.Reset(); _resourceToTreeNodeCache.Clear(); var document = new Document(); @@ -139,7 +140,7 @@ protected virtual AtomicResultObject ConvertOperation(OperationContainer? operat resourceObject = rootNode.GetResponseData().Single(); - _sparseFieldSetCache.Reset(); + _sparseFieldSetEvaluator.Reset(); _resourceToTreeNodeCache.Clear(); } @@ -224,9 +225,9 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (!isRelationship) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); + SparseFieldSetExpression fieldSetExpression = _sparseFieldSetEvaluator.GetSparseFieldSetForSerializer(resourceType); - resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); + resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSetExpression); resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, resource); } @@ -234,8 +235,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return resourceObject; } - protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, - IImmutableSet fieldSet) + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, SparseFieldSetExpression fieldSet) { ArgumentNullException.ThrowIfNull(resource); ArgumentNullException.ThrowIfNull(resourceType); @@ -245,7 +245,12 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc foreach (AttrAttribute attr in resourceType.Attributes) { - if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable<>.Id)) + if (attr.IsViewBlocked()) + { + continue; + } + + if (!fieldSet.Elements.TryGetValue(attr, out SparseFieldSetElementExpression? element) || attr.Property.Name == nameof(Identifiable<>.Id)) { continue; } @@ -263,12 +268,94 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc continue; } + if (value != null && attr.IsCompound) + { + IImmutableDictionary? requestedChildren = + element.Children.Count > 0 ? element.Children : null; + + if (attr.IsCollection && value is IEnumerable collection) + { + var prunedList = new List(); + + foreach (object? item in collection) + { + prunedList.Add(item == null ? null : ConvertCompoundObject(item, attr.CompoundType!, requestedChildren)); + } + + value = prunedList; + } + else + { + value = ConvertCompoundObject(value, attr.CompoundType!, requestedChildren); + } + } + attrMap.Add(attr.PublicName, value); } return attrMap.Count > 0 ? attrMap : null; } + private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, + IImmutableDictionary? childLookup) + { + var prunedMap = new Dictionary(); + + foreach (AttrAttribute childAttr in compoundType.Attributes) + { + if (childAttr.IsViewBlocked()) + { + continue; + } + + SparseFieldSetElementExpression? childElement = null; + + if (childLookup != null && !childLookup.TryGetValue(childAttr, out childElement)) + { + continue; + } + + object? childValue = childAttr.GetValue(compoundInstance); + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && childValue == null) + { + continue; + } + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && + Equals(childValue, RuntimeTypeConverter.GetDefaultValue(childAttr.Property.PropertyType))) + { + continue; + } + + if (childValue != null && childAttr.IsCompound) + { + IImmutableDictionary? deeperChildren = + childElement is { Children.Count: > 0 } ? childElement.Children : null; + + if (childAttr.IsCollection && childValue is IEnumerable collection) + { + var prunedList = new List(); + + foreach (object? item in collection) + { + prunedList.Add(item == null ? null : ConvertCompoundObject(item, childAttr.CompoundType!, deeperChildren)); + } + + childValue = prunedList; + } + else + { + childValue = ConvertCompoundObject(childValue, childAttr.CompoundType!, deeperChildren); + } + } + + prunedMap[childAttr.PublicName] = childValue; + } + + return prunedMap; + } + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, EndpointKind kind) { @@ -323,11 +410,11 @@ private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, Endpoi private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.ResourceType); + SparseFieldSetExpression fieldSet = _sparseFieldSetEvaluator.GetSparseFieldSetForSerializer(treeNode.ResourceType); foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) { - if (fieldSet.Contains(relationship)) + if (fieldSet.Elements.ContainsKey(relationship)) { PopulateRelationshipInResourceObject(treeNode, relationship); } diff --git a/test/AnnotationTests/AnnotationTests.csproj b/test/AnnotationTests/AnnotationTests.csproj index 6d2bfff1e6..61db47760a 100644 --- a/test/AnnotationTests/AnnotationTests.csproj +++ b/test/AnnotationTests/AnnotationTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0;netstandard2.0 + net10.0;netstandard2.0 diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs index 6afdb35994..2ffe3ec311 100644 --- a/test/AnnotationTests/Models/TreeNode.cs +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -11,7 +11,7 @@ namespace AnnotationTests.Models; GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class TreeNode : Identifiable { - [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)] + [Attr(PublicName = "name", IsCompound = false, Capabilities = AttrCapabilities.AllowSort)] public string? DisplayName { get; set; } [HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)] diff --git a/test/DapperTests/DapperTests.csproj b/test/DapperTests/DapperTests.csproj index 3aca3d377c..8c5e87b8bd 100644 --- a/test/DapperTests/DapperTests.csproj +++ b/test/DapperTests/DapperTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs index 6efc9db590..c3f71b8e33 100644 --- a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -229,6 +229,34 @@ WHERE t2."DurationInHours" IS NULL }); } + [Fact] + public async Task Can_filter_equals_null_on_non_nullable_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(createdAt,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().BeEmpty(); + } + [Fact] public async Task Can_filter_equals_on_enum_attribute_at_primary_endpoint() { diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index f6e1d014be..932588ed82 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -1,6 +1,6 @@  - net10.0;net9.0;net8.0 + net10.0 diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 88efcc61b5..187bb5f9bc 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -36,10 +36,10 @@ public void Can_add_resources_from_assembly_to_graph() ServiceProvider serviceProvider = _services.BuildServiceProvider(); var resourceGraph = serviceProvider.GetRequiredService(); - ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); + ResourceType? personType = resourceGraph.FindResourceType(); personType.Should().NotBeNull(); - ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); + ResourceType? todoItemType = resourceGraph.FindResourceType(); todoItemType.Should().NotBeNull(); } @@ -56,7 +56,7 @@ public void Can_add_resource_from_current_assembly_to_graph() ServiceProvider serviceProvider = _services.BuildServiceProvider(); var resourceGraph = serviceProvider.GetRequiredService(); - ResourceType? resourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); + ResourceType? resourceType = resourceGraph.FindResourceType(); resourceType.Should().NotBeNull(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index c6ed51fe4a..a23f3e4568 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -57,7 +57,7 @@ public async Task Cannot_include_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); @@ -97,7 +97,7 @@ public async Task Cannot_filter_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); @@ -137,7 +137,7 @@ public async Task Cannot_sort_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("sort"); @@ -177,7 +177,7 @@ public async Task Cannot_use_pagination_number_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -217,7 +217,7 @@ public async Task Cannot_use_pagination_size_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -257,7 +257,7 @@ public async Task Cannot_use_sparse_fieldset_at_operations_endpoint() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("fields[recordCompanies]"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index ea296e4973..1f3dbf96eb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -102,8 +102,6 @@ public async Task Hides_text_in_create_resource_with_side_effects() hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) }, options => options.WithStrictOrdering()); @@ -181,8 +179,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) }, options => options.WithStrictOrdering()); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs index e92a87c32b..2d1b001c1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs @@ -146,16 +146,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [InlineData(nameof(TypeContainer.TestNullableIPAddress))] [InlineData(nameof(TypeContainer.TestNullableIPNetwork))] [InlineData(nameof(TypeContainer.TestNullableVersion))] - public async Task Can_filter_equality_with_null_value(string propertyName) + public async Task Can_filter_equality_with_null_value_on_nullable_type(string propertyName) { // Arrange - TypeContainer existingContainer = _fakers.TypeContainer.GenerateOne(); - SetResourcePropertyValueToNull(existingContainer, propertyName); + List existingContainers = _fakers.TypeContainer.GenerateList(2); + SetResourcePropertyValueToNull(existingContainers[0], propertyName); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.TypeContainers.Add(existingContainer); + dbContext.TypeContainers.AddRange(existingContainers); await dbContext.SaveChangesAsync(); }); @@ -168,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(existingContainer.StringId); + responseDocument.Data.ManyValue[0].Id.Should().Be(existingContainers[0].StringId); } [Theory] @@ -190,6 +190,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [InlineData(nameof(TypeContainer.TestDecimal))] [InlineData(nameof(TypeContainer.TestComplex))] [InlineData(nameof(TypeContainer.TestChar))] + [InlineData(nameof(TypeContainer.TestString))] [InlineData(nameof(TypeContainer.TestRune))] [InlineData(nameof(TypeContainer.TestDateTimeOffset))] [InlineData(nameof(TypeContainer.TestDateTime))] @@ -198,26 +199,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [InlineData(nameof(TypeContainer.TestTimeSpan))] [InlineData(nameof(TypeContainer.TestEnum))] [InlineData(nameof(TypeContainer.TestGuid))] + [InlineData(nameof(TypeContainer.TestUri))] + [InlineData(nameof(TypeContainer.TestIPAddress))] [InlineData(nameof(TypeContainer.TestIPNetwork))] - public async Task Cannot_filter_equality_with_null_value(string propertyName) + [InlineData(nameof(TypeContainer.TestVersion))] + public async Task Can_filter_equality_with_null_on_non_nullable_type(string propertyName) { // Arrange + TypeContainer existingContainer = _fakers.TypeContainer.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.TypeContainers.Add(existingContainer); + await dbContext.SaveChangesAsync(); + }); + string route = $"/typeContainers?filter=equals({propertyName.Camelize()},null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().StartWith("Function, field name or value between quotes expected. Failed at position"); - error.Source.Should().NotBeNull(); - error.Source.Parameter.Should().Be("filter"); + responseDocument.Data.ManyValue.Should().BeEmpty(); } [Theory] @@ -346,6 +352,6 @@ private static string GetExpectedQueryStringErrorMessage(string propertyName, st } string propertyType = RuntimeTypeConverter.GetFriendlyTypeName(property.PropertyType); - return $"Failed to convert '{actualValue}' of type 'String' to type '{propertyType}'."; + return $"Failed to convert '{actualValue}' to type '{propertyType}'."; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/TypeAwareFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/TypeAwareFilterParser.cs index 8996c156ca..b2c0c5dff5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/TypeAwareFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/TypeAwareFilterParser.cs @@ -71,7 +71,7 @@ private static ConstantValueConverter CreateConverter(Type destinationType, F catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) { string destinationTypeName = RuntimeTypeConverter.GetFriendlyTypeName(destinationType); - throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationTypeName}'.", position, exception); + throw new QueryParseException($"Failed to convert '{stringValue}' to type '{destinationTypeName}'.", position, exception); } }; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs index a4cba72878..06cdec8d8e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -1,5 +1,6 @@ using System.Net; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -79,6 +80,20 @@ public override QueryExpression VisitIncludeElement(IncludeElementExpression exp return base.VisitIncludeElement(expression, argument); } + public override QueryExpression VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, object? argument) + { + if (expression.Field is RelationshipAttribute relationship) + { + _authScopeSet.Include(relationship, Permission.Read); + } + else if (expression.Field.ContainingType is ResourceType resourceType) + { + _authScopeSet.Include(resourceType, Permission.Read); + } + + return base.VisitSparseFieldSetElement(expression, argument); + } + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { foreach (ResourceFieldAttribute field in expression.Fields) @@ -87,9 +102,9 @@ public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpres { _authScopeSet.Include(relationship, Permission.Read); } - else + else if (field.ContainingType is ResourceType resourceType) { - _authScopeSet.Include(field.Type, Permission.Read); + _authScopeSet.Include(resourceType, Permission.Read); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesDbContext.cs new file mode 100644 index 0000000000..2fe22ed4c3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesDbContext.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class CompoundAttributesDbContext(DbContextOptions options) + : TestableDbContext(options) +{ + public DbSet AddressBooks => Set(); + public DbSet Contacts => Set(); + public DbSet Notes => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity(b => + { + b.ComplexProperty(a => a.EmergencyContact, cb => cb.ToJson()); + b.ComplexCollection(a => a.Favorites, cb => cb.ToJson()); + b.HasMany(a => a.Contacts).WithOne(c => c.AddressBook); + }); + + builder.Entity(b => + { + b.ComplexProperty(c => c.Content, cb => cb.ToJson()); + b.HasMany(c => c.Notes).WithOne(); + }); + + base.OnModelCreating(builder); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs new file mode 100644 index 0000000000..87a26b8943 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -0,0 +1,131 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +internal sealed class CompoundAttributesFakers +{ + private readonly Lazy> _lazyAddressBookFaker; + private readonly Lazy> _lazyContactFaker; + private readonly Lazy> _lazyContactRootFaker; + private readonly Lazy> _lazyContactNameFaker; + private readonly Lazy> _lazyContactCompanyFaker; + private readonly Lazy> _lazyContactAddressFaker; + private readonly Lazy> _lazyContactPhoneNumberFaker; + private readonly Lazy> _lazyContactDateFaker; + + private Faker ContactName => _lazyContactNameFaker.Value; + private Faker ContactAddress => _lazyContactAddressFaker.Value; + private Faker ContactDate => _lazyContactDateFaker.Value; + + public Faker ContactPhoneNumber => _lazyContactPhoneNumberFaker.Value; + public Faker AddressBook => _lazyAddressBookFaker.Value; + public Faker Contact => _lazyContactFaker.Value; + public Faker ContactRoot => _lazyContactRootFaker.Value; + public Faker ContactCompany => _lazyContactCompanyFaker.Value; + + public CompoundAttributesFakers() + { + _lazyContactNameFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(name => name.FirstName, faker => faker.Name.FirstName()) + .RuleFor(name => name.MiddleName, faker => faker.Name.FirstName()) + .RuleFor(name => name.LastName, faker => faker.Name.LastName()) + .RuleFor(name => name.NickName, faker => faker.Internet.UserName()) + .RuleFor(name => name.MaidenName, faker => faker.Name.LastName()) + .RuleFor(name => name.SecretAlias, faker => faker.Name.FindName())); + + _lazyContactCompanyFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(company => company.Name, faker => faker.Company.CompanyName()) + .RuleFor(company => company.Department, faker => faker.Commerce.Department()) + .RuleFor(company => company.BusinessHours, _ => + [ + TimeSpan.FromHours(9), + TimeSpan.FromHours(17) + ])); + + _lazyContactAddressFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(address => address.IsoCountryCode, faker => faker.Address.CountryCode()) + .RuleFor(address => address.City, faker => faker.Address.City()) + .RuleFor(address => address.Line1, faker => faker.Address.StreetAddress()) + .RuleFor(address => address.PostalCode, faker => faker.Address.ZipCode()) + .RuleFor(address => address.Label, faker => faker.PickRandom("home", "work", "other"))); + + _lazyContactPhoneNumberFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(phone => phone.CountryPrefix, faker => $"+{faker.Random.Number(1, 99)}") + .RuleFor(phone => phone.Number, faker => faker.Phone.PhoneNumber("###-###-####")) + .RuleFor(phone => phone.Label, faker => faker.PickRandom("mobile", "home", "work"))); + + _lazyContactDateFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(date => date.Year, faker => (ushort)faker.Random.Number(1950, 2020)) + .RuleFor(date => date.Month, faker => (byte)faker.Random.Number(1, 12)) + .RuleFor(date => date.Day, faker => (byte)faker.Random.Number(1, 28))); + + _lazyContactRootFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(root => root.Name, _ => ContactName.Generate()) + .RuleFor(root => root.Company, _ => ContactCompany.Generate()) + .RuleFor(root => root.BirthDate, _ => ContactDate.Generate()) + .RuleFor(root => root.SecurityPin, faker => faker.Random.Replace("####")) + .RuleFor(root => root.Ratings, faker => + [ + faker.Random.Number(1, 5), + null, + faker.Random.Number(1, 5) + ]) + .RuleFor(root => root.PhoneNumbers, _ => ContactPhoneNumber.GenerateList(2)) + .RuleFor(root => root.Addresses, _ => ContactAddress.GenerateList(1)) + .RuleFor(root => root.EmailAddresses, faker => + [ + new LabeledValue + { + Value = faker.Internet.Email(), + Label = "primary" + } + ]) + .RuleFor(root => root.Relations, faker => + [ + new LabeledValue + { + Value = faker.Name.FullName(), + Label = "friend" + } + ]) + .RuleFor(root => root.ImportantDates, faker => + [ + new LabeledValue + { + Value = DateOnly.FromDateTime(faker.Date.Past()), + Label = "anniversary" + } + ])); + + _lazyContactFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(contact => contact.Content, _ => ContactRoot.Generate()) + .RuleFor(contact => contact.Remarks, faker => faker.Lorem.Paragraph())); + + _lazyAddressBookFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(book => book.Name, faker => faker.Commerce.Department()) + .RuleFor(book => book.EmergencyContact, _ => ContactRoot.Generate()) + .RuleFor(book => book.Favorites, _ => ContactRoot.GenerateList(2)) + .RuleFor(book => book.Categories, faker => + [ + faker.Commerce.Categories(1)[0], + faker.Commerce.Categories(1)[0] + ]) + .RuleFor(book => book.SyncDeviceIds, faker => + [ + faker.Random.Guid(), + faker.Random.Guid() + ])); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs new file mode 100644 index 0000000000..1603ecbd8f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -0,0 +1,1078 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +public sealed class CompoundAttributesReadTests + : IClassFixture, CompoundAttributesDbContext>> +{ + private readonly IntegrationTestContext, CompoundAttributesDbContext> _testContext; + private readonly CompoundAttributesFakers _fakers = new(); + + public CompoundAttributesReadTests(IntegrationTestContext, CompoundAttributesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Can_get_resource_by_ID_with_compound_attributes() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(contact.StringId); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("remarks").WhoseValue.Should().Be(contact.Remarks); + + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); + object? contentValue = responseDocument.Data.SingleValue.Attributes["content"]; + contentValue.Should().BeOfType(); + + var contentElement = (JsonElement)contentValue; + contentElement.GetProperty("name").GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + contentElement.GetProperty("name").GetProperty("displayName").GetString().Should().Be(contact.Content.Name.DisplayName); + + if (contact.Content.Company != null) + { + contentElement.GetProperty("company").GetProperty("name").GetString().Should().Be(contact.Content.Company.Name); + } + + contentElement.GetProperty("phoneNumbers").GetArrayLength().Should().Be(contact.Content.PhoneNumbers.Count); + + contentElement.GetProperty("name").GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + contentElement.GetProperty("name").GetProperty("displayName").GetString().Should().Be(contact.Content.Name.DisplayName); + } + + [Fact] + public async Task Can_filter_by_nested_compound_attribute_equality() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(3); + contacts[0].Content.Name.LastName = "Smith"; + contacts[1].Content.Name.LastName = "Jones"; + contacts[2].Content.Name.LastName = "Smith"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=equals(content.name.lastName,'Smith')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Select(resource => resource.Id).Should().BeEquivalentTo(contacts[0].StringId, contacts[2].StringId); + } + + [Fact] + public async Task Can_filter_by_nested_compound_attribute_text_matching() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.Name.LastName = "Smithson"; + contacts[1].Content.Name.LastName = "Johnson"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=startsWith(content.name.lastName,'Smi')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_by_null_on_compound_attribute() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.Company = null; + contacts[1].Content.Company = _fakers.ContactCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=equals(content.company,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_by_null_on_root_compound_attribute() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=equals(content,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().BeEmpty(); + } + + [Fact] + public async Task Can_filter_on_compound_collection_using_has() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "111-111", + Label = "work" + }, + new ContactPhoneNumber + { + Number = "222-222", + Label = "mobile" + } + ]; + + contacts[1].Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "333-333", + Label = "home" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.phoneNumbers,equals(label,'mobile'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_on_primitive_collection_using_has_and_element_value() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + + addressBooks[0].Categories = + [ + "https://example.com/sync", + "https://backup.org" + ]; + + addressBooks[1].Categories = ["https://other.com/api"]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(categories,equals(_,'https://example.com/sync'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_on_primitive_collection_using_has_and_text_matching() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + addressBooks[0].Categories = ["https://contoso.com/sync"]; + addressBooks[1].Categories = ["https://fabrikam.com/sync"]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(categories,contains(_,'contoso'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_by_count_on_compound_collection() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "111-111" + }, + new ContactPhoneNumber + { + Number = "222-222" + }, + new ContactPhoneNumber + { + Number = "333-333" + } + ]; + + contacts[1].Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "444-444" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=greaterThan(count(content.phoneNumbers),'2')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_by_count_on_primitive_collection() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + + addressBooks[0].Categories = + [ + "url1", + "url2", + "url3" + ]; + + addressBooks[1].Categories = ["url1"]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=greaterThan(count(categories),'1')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_sort_by_nested_compound_attributes() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(3); + contacts[0].Content.Name.LastName = "Smith"; + contacts[0].Content.Name.FirstName = "Zach"; + contacts[1].Content.Name.LastName = "Adams"; + contacts[1].Content.Name.FirstName = "John"; + contacts[2].Content.Name.LastName = "Smith"; + contacts[2].Content.Name.FirstName = "Alice"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?sort=content.name.lastName,content.name.firstName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[1].StringId); // Adams + responseDocument.Data.ManyValue[1].Id.Should().Be(contacts[2].StringId); // Smith, Alice + responseDocument.Data.ManyValue[2].Id.Should().Be(contacts[0].StringId); // Smith, Zach + } + + [Fact] + public async Task Can_sort_descending_by_nested_compound_attribute() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.Name.LastName = "Adams"; + contacts[1].Content.Name.LastName = "Smith"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?sort=-content.name.lastName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[1].StringId); // Smith + responseDocument.Data.ManyValue[1].Id.Should().Be(contacts[0].StringId); // Adams + } + + [Fact] + public async Task Can_filter_through_relationship_with_compound_attribute() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + addressBook.EmergencyContact = _fakers.ContactRoot.Generate(); + addressBook.EmergencyContact.Name.LastName = "SpecialName"; + + Contact contact = _fakers.Contact.Generate(); + contact.AddressBook = addressBook; + + Contact otherContact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contact, otherContact); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=equals(addressBook.emergencyContact.name.lastName,'SpecialName')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contact.StringId); + } + + [Fact] + public async Task Can_filter_to_many_relationship_with_compound_collection_condition() + { + // Arrange + AddressBook addressBook1 = _fakers.AddressBook.Generate(); + Contact contact1 = _fakers.Contact.Generate(); + + contact1.Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "999", + Label = "hotline" + } + ]; + + addressBook1.Contacts.Add(contact1); + + AddressBook addressBook2 = _fakers.AddressBook.Generate(); + Contact contact2 = _fakers.Contact.Generate(); + + contact2.Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "111", + Label = "home" + } + ]; + + addressBook2.Contacts.Add(contact2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBook1, addressBook2); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(contacts,has(content.phoneNumbers,equals(label,'hotline')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBook1.StringId); + } + + [Fact] + public async Task Can_filter_categories_using_startsWith() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + + addressBooks[0].Categories = + [ + "Personal", + "Work" + ]; + + addressBooks[1].Categories = + [ + "Business", + "Archive" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(categories,startsWith(_,'Pers'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_categories_using_contains() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + + addressBooks[0].Categories = + [ + "Personal", + "Networking" + ]; + + addressBooks[1].Categories = ["Family"]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(categories,contains(_,'ork'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_syncDeviceIds_by_has_guid_equality() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + var targetGuid = Guid.NewGuid(); + + addressBooks[0].SyncDeviceIds = + [ + targetGuid, + Guid.NewGuid() + ]; + + addressBooks[1].SyncDeviceIds = [Guid.NewGuid()]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks?filter=has(syncDeviceIds,equals(_,'{targetGuid}'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_syncDeviceIds_by_count() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + + addressBooks[0].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; + + addressBooks[1].SyncDeviceIds = []; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=greaterThan(count(syncDeviceIds),'0')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_sort_by_scalar_collection_count() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(3); + addressBooks[0].SyncDeviceIds = [Guid.NewGuid()]; + + addressBooks[1].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + ]; + + addressBooks[2].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?sort=count(syncDeviceIds)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(addressBooks[2].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(addressBooks[1].StringId); + } + + [Fact] + public async Task Can_sort_by_descending_scalar_collection_count() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(3); + addressBooks[0].SyncDeviceIds = [Guid.NewGuid()]; + + addressBooks[1].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + ]; + + addressBooks[2].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?sort=-count(syncDeviceIds)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[1].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(addressBooks[2].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Can_filter_businessHours_in_company() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.Company = new ContactCompany + { + Name = "Company A", + BusinessHours = + [ + TimeSpan.FromHours(9), + TimeSpan.FromHours(17) + ] + }; + + contacts[1].Content.Company = new ContactCompany + { + Name = "Company B", + BusinessHours = + [ + TimeSpan.FromHours(8), + TimeSpan.FromHours(16) + ] + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.company.businessHours,equals(_,'09:00:00'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_ratings_with_integer_element() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.Ratings = + [ + 5, + null, + 4 + ]; + + contacts[1].Content.Ratings = + [ + 1, + 2, + 3 + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.ratings,equals(_,'5'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_importantDates_by_value() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2020, 5, 15), + Label = "anniversary" + } + ]; + + contacts[1].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2018, 1, 1), + Label = "hired" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.importantDates,equals(value,'2020-05-15'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_importantDates_by_null_value() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2020, 5, 15), + Label = "anniversary" + } + ]; + + contacts[1].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2018, 1, 1), + Label = "hired" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.importantDates,equals(value,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().BeEmpty(); + } + + [Fact] + public async Task Can_filter_importantDates_by_label() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + + contacts[0].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2020, 5, 15), + Label = "anniversary" + } + ]; + + contacts[1].Content.ImportantDates = + [ + new LabeledValue + { + Value = new DateOnly(2018, 1, 1), + Label = "hired" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.importantDates,equals(label,'anniversary'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_sort_by_compound_collection_count() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(3); + addressBooks[0].Favorites = _fakers.ContactRoot.GenerateList(1); + addressBooks[1].Favorites = _fakers.ContactRoot.GenerateList(3); + addressBooks[2].Favorites = _fakers.ContactRoot.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?sort=count(favorites)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(addressBooks[2].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(addressBooks[1].StringId); + } + + [Fact] + public async Task Can_sort_by_descending_compound_collection_count() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(3); + contacts[0].Content.PhoneNumbers = _fakers.ContactPhoneNumber.GenerateList(1); + contacts[1].Content.PhoneNumbers = _fakers.ContactPhoneNumber.GenerateList(3); + contacts[2].Content.PhoneNumbers = _fakers.ContactPhoneNumber.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?sort=-count(content.phoneNumbers)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[1].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(contacts[2].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_using_predicate_less_has_on_compound_collection() + { + // Arrange + List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.PhoneNumbers = _fakers.ContactPhoneNumber.GenerateList(2); + contacts[1].Content.PhoneNumbers = []; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.AddRange(contacts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/contacts?filter=has(content.phoneNumbers)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(contacts[0].StringId); + } + + [Fact] + public async Task Can_filter_using_predicate_less_has_on_scalar_collection() + { + // Arrange + List addressBooks = _fakers.AddressBook.GenerateList(2); + addressBooks[0].Categories = ["CategoryA"]; + addressBooks[1].Categories = []; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=has(categories)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(addressBooks[0].StringId); + } + + [Fact] + public async Task Restricted_SecurityPin_is_omitted_without_sparse_fieldset() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + contact.Content.SecurityPin = "SecretPin123"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseRaw) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseRaw.Should().NotContain("SecretPin123"); + responseRaw.Should().NotContain("securityPin"); + } + + [Fact] + public async Task Restricted_SecurityPin_is_omitted_with_whole_compound_sparse_fieldset() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + contact.Content.SecurityPin = "SecretPin456"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content"; + + // Act + (HttpResponseMessage httpResponse, string responseRaw) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseRaw.Should().NotContain("SecretPin456"); + responseRaw.Should().NotContain("securityPin"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs new file mode 100644 index 0000000000..ba1404947f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -0,0 +1,505 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +public sealed class CompoundAttributesSparseFieldSetTests + : IClassFixture, CompoundAttributesDbContext>> +{ + private readonly IntegrationTestContext, CompoundAttributesDbContext> _testContext; + private readonly CompoundAttributesFakers _fakers = new(); + + public CompoundAttributesSparseFieldSetTests(IntegrationTestContext, CompoundAttributesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Can_select_leaf_property_in_compound_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement attributes = responseElement.GetProperty("data").GetProperty("attributes"); + attributes.TryGetProperty("remarks", out _).Should().BeFalse(); + + JsonElement contentElement = attributes.GetProperty("content"); + contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + nameElement.TryGetProperty("displayName", out _).Should().BeFalse(); + contentElement.TryGetProperty("company", out _).Should().BeFalse(); + contentElement.TryGetProperty("phoneNumbers", out _).Should().BeFalse(); + } + + [Fact] + public async Task Can_select_leaf_property_in_compound_attribute_along_with_top_level_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName,remarks"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement attributes = responseElement.GetProperty("data").GetProperty("attributes"); + attributes.TryGetProperty("remarks", out _).Should().BeTrue(); + + JsonElement contentElement = attributes.GetProperty("content"); + contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + } + + [Fact] + public async Task Can_select_whole_compound_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement attributes = responseElement.GetProperty("data").GetProperty("attributes"); + attributes.TryGetProperty("remarks", out _).Should().BeFalse(); + + JsonElement contentElement = attributes.GetProperty("content"); + contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.GetProperty("firstName").GetString().Should().Be(contact.Content.Name.FirstName); + nameElement.GetProperty("displayName").GetString().Should().Be(contact.Content.Name.DisplayName); + } + + [Fact] + public async Task Can_select_top_level_attribute_and_omit_compound_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=remarks"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("remarks"); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("content"); + } + + [Fact] + public async Task Can_select_primitive_collection() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=categories"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("name"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("categories"); + } + + [Fact] + public async Task Can_select_collection_of_compound_attributes() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=favorites"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("name"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("favorites"); + } + + [Fact] + public async Task Can_select_nested_properties_inside_collection_of_compound_attributes() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + addressBook.Favorites = _fakers.ContactRoot.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=favorites.name.lastName"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement attributes = responseElement.GetProperty("data").GetProperty("attributes"); + attributes.TryGetProperty("name", out _).Should().BeFalse(); + + JsonElement favoritesElement = attributes.GetProperty("favorites"); + favoritesElement.GetArrayLength().Should().Be(2); + + for (int i = 0; i < 2; i++) + { + JsonElement favorite = favoritesElement[i]; + favorite.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(addressBook.Favorites[i].Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + favorite.TryGetProperty("company", out _).Should().BeFalse(); + } + } + + [Fact] + public async Task Can_select_multiple_sibling_properties_in_sparse_fieldsets() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.firstName,content.name.lastName"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement content = responseElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + content.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("firstName").GetString().Should().Be(contact.Content.Name.FirstName); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("middleName", out _).Should().BeFalse(); + content.TryGetProperty("company", out _).Should().BeFalse(); + } + + [Fact] + public async Task Can_select_properties_across_different_compound_branches() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + contact.Content.Company = new ContactCompany + { + Name = "Acme Corp", + Department = "Engineering" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName,content.company.name"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement content = responseElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + content.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + + content.TryGetProperty("company", out JsonElement companyElement).Should().BeTrue(); + companyElement.GetProperty("name").GetString().Should().Be(contact.Content.Company!.Name); + companyElement.TryGetProperty("department", out _).Should().BeFalse(); + } + + [Fact] + public async Task Can_combine_sparse_fieldsets_with_relationship_includes() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + Contact contact = _fakers.Contact.Generate(); + contact.AddressBook = addressBook; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?include=addressBook&fields[contacts]=content.name.lastName,addressBook&fields[addressBooks]=name"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement included = responseElement.GetProperty("included"); + included.GetArrayLength().Should().Be(1); + + JsonElement includedAttributes = included[0].GetProperty("attributes"); + includedAttributes.TryGetProperty("name", out _).Should().BeTrue(); + includedAttributes.TryGetProperty("categories", out _).Should().BeFalse(); + + JsonElement content = responseElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + content.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + } + + [Fact] + public async Task Selecting_only_relationships_implicitly_materializes_compound_properties() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + Contact contact = _fakers.Contact.Generate(); + contact.AddressBook = addressBook; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=addressBook"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("addressBook"); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(contact.Id); + contactInDatabase.Content.Should().NotBeNull(); + contactInDatabase.Content.Name.Should().NotBeNull(); + contactInDatabase.Content.Name.LastName.Should().Be(contact.Content.Name.LastName); + }); + } + + [Fact] + public async Task View_blocked_nested_attribute_is_pruned_when_retrieving_compound_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + contact.Content.Name.SecretAlias = "Classified"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/contacts/{contact.StringId}?fields[contacts]=content"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement name = responseElement.GetProperty("data").GetProperty("attributes").GetProperty("content").GetProperty("name"); + name.TryGetProperty("lastName", out _).Should().BeTrue(); + name.TryGetProperty("secretAlias", out _).Should().BeFalse(); + } + + [Fact] + public async Task Cannot_select_view_blocked_nested_attribute() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + var parameterValue = new MarkedText("content.name.^secretAlias", '^'); + string route = $"/contacts/{contact.StringId}?fields[contacts]={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified fieldset is invalid."); + error.Detail.Should().Be($"Retrieving attribute 'secretAlias' is not allowed. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("fields[contacts]"); + } + + [Fact] + public async Task Can_select_leaf_property_in_compound_attribute_at_secondary_endpoint() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + Contact contact = _fakers.Contact.Generate(); + contact.AddressBook = addressBook; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks/{addressBook.StringId}/contacts?fields[contacts]=content.name.lastName"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement data = responseElement.GetProperty("data"); + data.GetArrayLength().Should().Be(1); + + JsonElement attributes = data[0].GetProperty("attributes"); + attributes.TryGetProperty("remarks", out _).Should().BeFalse(); + + JsonElement content = attributes.GetProperty("content"); + content.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); + nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); + nameElement.TryGetProperty("firstName", out _).Should().BeFalse(); + content.TryGetProperty("company", out _).Should().BeFalse(); + } + + [Fact] + public async Task Can_get_relationship_linkage_for_resource_with_compound_attribute() + { + // Arrange + AddressBook addressBook = _fakers.AddressBook.Generate(); + Contact contact = _fakers.Contact.Generate(); + contact.AddressBook = addressBook; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(addressBook); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/addressBooks/{addressBook.StringId}/relationships/contacts"; + + // Act + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + JsonElement data = responseElement.GetProperty("data"); + data.GetArrayLength().Should().Be(1); + + JsonElement identifier = data[0]; + identifier.GetProperty("type").GetString().Should().Be("contacts"); + identifier.GetProperty("id").GetString().Should().Be(contact.StringId); + identifier.TryGetProperty("attributes", out _).Should().BeFalse(); + identifier.TryGetProperty("relationships", out _).Should().BeFalse(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs new file mode 100644 index 0000000000..5c3f3808e2 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -0,0 +1,535 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +public sealed class CompoundAttributesValidationTests + : IClassFixture, CompoundAttributesDbContext>> +{ + private readonly IntegrationTestContext, CompoundAttributesDbContext> _testContext; + private readonly CompoundAttributesFakers _fakers = new(); + + public CompoundAttributesValidationTests(IntegrationTestContext, CompoundAttributesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_sort_on_compound_object() + { + // Arrange + const string route = "/contacts?sort=content"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Contain("zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute"); + } + + [Fact] + public async Task Cannot_sort_on_collection_of_compound_attributes() + { + // Arrange + const string route = "/contacts?sort=content.phoneNumbers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Contain("zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute"); + } + + [Fact] + public async Task Cannot_compare_compound_object_directly_to_scalar_constant() + { + // Arrange + const string route = "/contacts?filter=equals(content,'test')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Contain("null expected"); + } + + [Fact] + public async Task Cannot_navigate_directly_into_collection_without_has() + { + // Arrange + const string route = "/contacts?filter=equals(content.phoneNumbers.number,'123')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound non-collection attributes, followed by a to-one relationship or an attribute"); + } + + [Fact] + public async Task Cannot_filter_non_collection_with_has() + { + // Arrange + const string route = "/contacts?filter=has(content)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute"); + } + + [Fact] + public async Task Cannot_use_count_on_non_collection() + { + // Arrange + const string route = "/contacts?filter=equals(count(content),1)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute"); + } + + [Fact] + public async Task Cannot_use_isType_inside_compound_attribute() + { + // Arrange + const string route = "/contacts?filter=has(content.phoneNumbers,isType(null,contacts))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Contain("Function 'isType' can only be used on resource types"); + } + + [Fact] + public async Task Cannot_use_underscore_on_compound_collection() + { + // Arrange + const string route = "/contacts?filter=has(content.phoneNumbers,startsWith(_.countryPrefix,'%2B31'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(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("Function or field name expected. Failed at position 37: has(content.phoneNumbers,startsWith(^_.countryPrefix,'+31'))"); + } + + [Fact] + public async Task Cannot_create_contact_with_missing_required_compound_property() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "John", + lastName = "Doe" + }, + birthDate = new + { + year = 1990, + day = 15 + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/birthDate/month"); + } + + [Fact] + public async Task Cannot_create_contact_with_out_of_range_compound_property() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "John", + lastName = "Doe" + }, + birthDate = new + { + year = 1990, + month = 15, + day = 15 + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/birthDate/month"); + } + + [Fact] + public async Task Cannot_create_contact_with_empty_required_collection_element_property() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "John", + lastName = "Doe" + }, + phoneNumbers = new[] + { + new + { + number = string.Empty, + label = "work" + } + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/phoneNumbers/0/number"); + } + + [Fact] + public async Task Cannot_create_contact_with_blocked_AllowCreate_compound_property() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "John", + lastName = "Doe", + nickName = "Johnny" + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); + error.Detail.Should().Be("The attribute 'nickName' on compound type 'ContactName' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/name/nickName"); + } + + [Fact] + public async Task Cannot_update_contact_with_blocked_AllowChange_compound_property() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = contact.StringId, + attributes = new + { + content = new + { + name = new + { + lastName = contact.Content.Name.LastName, + maidenName = "NewMaidenName" + } + } + } + } + }; + + string route = $"/contacts/{contact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); + error.Detail.Should().Be("The attribute 'maidenName' on compound type 'ContactName' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/name/maidenName"); + } + + [Fact] + public async Task Cannot_set_read_only_compound_property_on_create() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + lastName = "Doe", + displayName = "John Doe" + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + error.Detail.Should().Be("Attribute 'displayName' on compound type 'ContactName' is read-only."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/name/displayName"); + } + + [Fact] + public async Task Cannot_set_read_only_compound_property_on_update() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = contact.StringId, + attributes = new + { + content = new + { + name = new + { + lastName = contact.Content.Name.LastName, + displayName = "Changed Display Name" + } + } + } + } + }; + + string route = $"/contacts/{contact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + error.Detail.Should().Be("Attribute 'displayName' on compound type 'ContactName' is read-only."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/name/displayName"); + } + + [Fact] + public async Task Cannot_send_unknown_compound_property_when_disallowed() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + lastName = "Doe", + extraProp = "Unexpected" + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); + error.Detail.Should().Be("Attribute 'extraProp' does not exist on compound type 'ContactName'."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/content/name/extraProp"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs new file mode 100644 index 0000000000..75551d1886 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -0,0 +1,666 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +public sealed class CompoundAttributesWriteTests + : IClassFixture, CompoundAttributesDbContext>> +{ + private readonly IntegrationTestContext, CompoundAttributesDbContext> _testContext; + private readonly CompoundAttributesFakers _fakers = new(); + + public CompoundAttributesWriteTests(IntegrationTestContext, CompoundAttributesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Can_create_resource_with_compound_attributes() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "John", + lastName = "Doe" + }, + company = new + { + name = "Acme Corp", + department = "Engineering" + }, + phoneNumbers = new[] + { + new + { + number = "555-1234", + label = "work" + }, + new + { + number = "555-5678", + label = "mobile" + } + } + }, + remarks = "New contact note" + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + string newContactId = responseDocument.Data.SingleValue.Id!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(long.Parse(newContactId)); + + contactInDatabase.Remarks.Should().Be("New contact note"); + contactInDatabase.Content.Should().NotBeNull(); + contactInDatabase.Content.Name.FirstName.Should().Be("John"); + contactInDatabase.Content.Name.LastName.Should().Be("Doe"); + contactInDatabase.Content.Name.DisplayName.Should().Be("John Doe"); + contactInDatabase.Content.Company.Should().NotBeNull(); + contactInDatabase.Content.Company!.Name.Should().Be("Acme Corp"); + contactInDatabase.Content.Company.Department.Should().Be("Engineering"); + contactInDatabase.Content.PhoneNumbers.Should().HaveCount(2); + contactInDatabase.Content.PhoneNumbers[0].Number.Should().Be("555-1234"); + contactInDatabase.Content.PhoneNumbers[1].Number.Should().Be("555-5678"); + }); + } + + [Fact] + public async Task Can_create_resource_with_primitive_collection() + { + // Arrange + var requestBody = new + { + data = new + { + type = "addressBooks", + attributes = new + { + name = "Work Address Book", + categories = new[] + { + "https://example.com/sync", + "https://backup.example.com" + } + } + } + }; + + const string route = "/addressBooks"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + string newAddressBookId = responseDocument.Data.SingleValue.Id!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(long.Parse(newAddressBookId)); + + addressBookInDatabase.Name.Should().Be("Work Address Book"); + addressBookInDatabase.Categories.Should().NotBeNull(); + addressBookInDatabase.Categories.Should().BeEquivalentTo("https://example.com/sync", "https://backup.example.com"); + }); + } + + [Fact] + public async Task Can_patch_resource_replacing_compound_attribute() + { + // Arrange + Contact existingContact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(existingContact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = existingContact.StringId, + attributes = new + { + content = new + { + name = new + { + firstName = "Jane", + lastName = "Smith" + }, + phoneNumbers = new[] + { + new + { + number = "999-0000", + label = "direct" + } + } + } + } + } + }; + + string route = $"/contacts/{existingContact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(existingContact.Id); + + contactInDatabase.Content.Name.FirstName.Should().Be("Jane"); + contactInDatabase.Content.Name.LastName.Should().Be("Smith"); + contactInDatabase.Content.Name.DisplayName.Should().Be("Jane Smith"); + contactInDatabase.Content.Company.Should().BeNull(); + contactInDatabase.Content.PhoneNumbers.Should().HaveCount(1); + contactInDatabase.Content.PhoneNumbers[0].Number.Should().Be("999-0000"); + }); + } + + [Fact] + public async Task Can_patch_resource_setting_compound_attribute_to_null() + { + // Arrange + AddressBook existingAddressBook = _fakers.AddressBook.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(existingAddressBook); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "addressBooks", + id = existingAddressBook.StringId, + attributes = new Dictionary + { + ["emergencyContact"] = null + } + } + }; + + string route = $"/addressBooks/{existingAddressBook.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(existingAddressBook.Id); + + addressBookInDatabase.EmergencyContact.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_patch_resource_replacing_primitive_collection() + { + // Arrange + AddressBook existingAddressBook = _fakers.AddressBook.Generate(); + + existingAddressBook.Categories = + [ + "https://old1.com", + "https://old2.com" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.Add(existingAddressBook); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "addressBooks", + id = existingAddressBook.StringId, + attributes = new + { + categories = new[] + { + "https://new1.com" + } + } + } + }; + + string route = $"/addressBooks/{existingAddressBook.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(existingAddressBook.Id); + + addressBookInDatabase.Categories.Should().BeEquivalentTo("https://new1.com"); + }); + } + + [Fact] + public async Task Can_patch_resource_clearing_compound_collection() + { + // Arrange + Contact existingContact = _fakers.Contact.Generate(); + + existingContact.Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "111-222" + } + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(existingContact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = existingContact.StringId, + attributes = new + { + content = new + { + name = new + { + firstName = existingContact.Content.Name.FirstName, + lastName = existingContact.Content.Name.LastName + }, + phoneNumbers = Array.Empty() + } + } + } + }; + + string route = $"/contacts/{existingContact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(existingContact.Id); + + contactInDatabase.Content.PhoneNumbers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_patch_compound_property_with_blocked_AllowCreate() + { + // Arrange + Contact existingContact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(existingContact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = existingContact.StringId, + attributes = new + { + content = new + { + name = new + { + lastName = existingContact.Content.Name.LastName, + nickName = "NewNickName" + } + } + } + } + }; + + string route = $"/contacts/{existingContact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(existingContact.Id); + contactInDatabase.Content.Name.NickName.Should().Be("NewNickName"); + }); + } + + [Fact] + public async Task Can_create_resource_with_blocked_AllowChange_compound_property() + { + // Arrange + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "Alice", + lastName = "Wonderland", + maidenName = "OriginalFamily" + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + string newContactId = responseDocument.Data.SingleValue.Id!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(long.Parse(newContactId)); + contactInDatabase.Content.Name.MaidenName.Should().Be("OriginalFamily"); + }); + } + + [Fact] + public async Task Can_create_and_patch_resource_with_custom_public_name_zipCode() + { + // Arrange: Create with zipCode + var createRequestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "Bob", + lastName = "Builder" + }, + addresses = new[] + { + new + { + isoCountryCode = "US", + city = "Metropolis", + zipCode = "12345" + } + } + } + } + } + }; + + const string postRoute = "/contacts"; + + // Act: Create + (HttpResponseMessage postResponse, Document postDocument) = await _testContext.ExecutePostAsync(postRoute, createRequestBody); + + // Assert: Create + postResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + postDocument.Data.SingleValue.Should().NotBeNull(); + string newContactId = postDocument.Data.SingleValue!.Id!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(long.Parse(newContactId)); + contactInDatabase.Content.Addresses.Should().HaveCount(1); + contactInDatabase.Content.Addresses[0].PostalCode.Should().Be("12345"); + }); + + // Act: Patch with new zipCode + var patchRequestBody = new + { + data = new + { + type = "contacts", + id = newContactId, + attributes = new + { + content = new + { + name = new + { + lastName = "Builder" + }, + addresses = new[] + { + new + { + isoCountryCode = "US", + city = "Metropolis", + zipCode = "99999" + } + } + } + } + } + }; + + string patchRoute = $"/contacts/{newContactId}"; + (HttpResponseMessage patchResponse, string patchDocument) = await _testContext.ExecutePatchAsync(patchRoute, patchRequestBody); + + // Assert: Patch + patchResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + patchDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Contact contactInDatabase = await dbContext.Contacts.FirstWithIdAsync(long.Parse(newContactId)); + contactInDatabase.Content.Addresses[0].PostalCode.Should().Be("99999"); + }); + + // Act: Read with sparse fieldset on zipCode + string getRoute = $"/contacts/{newContactId}?fields[contacts]=content.addresses.zipCode"; + (HttpResponseMessage getResponse, string getRaw) = await _testContext.ExecuteGetAsync(getRoute); + + // Assert: Read + getResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + getRaw.Should().Contain("zipCode"); + getRaw.Should().Contain("99999"); + } + + [Fact] + public async Task Can_create_resource_with_unknown_compound_property_when_allowed() + { + // Arrange + var options = (JsonApiOptions)_testContext.App.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; + + try + { + var requestBody = new + { + data = new + { + type = "contacts", + attributes = new + { + content = new + { + name = new + { + firstName = "Extra", + lastName = "Tester", + unknownNestedField = "Ignored" + } + } + } + } + }; + + const string route = "/contacts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + responseDocument.Data.SingleValue.Should().NotBeNull(); + } + finally + { + options.AllowUnknownFieldsInRequestBody = false; + } + } + + [Fact] + public async Task Patching_resource_with_identical_compound_attributes_is_idempotent_with_no_sql_update() + { + // Arrange + Contact contact = _fakers.Contact.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Contacts.Add(contact); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "contacts", + id = contact.StringId, + attributes = new + { + content = new + { + name = new + { + firstName = contact.Content.Name.FirstName, + middleName = contact.Content.Name.MiddleName, + lastName = contact.Content.Name.LastName, + nickName = contact.Content.Name.NickName + }, + company = contact.Content.Company == null + ? null + : new + { + name = contact.Content.Company.Name, + department = contact.Content.Company.Department, + businessHours = contact.Content.Company.BusinessHours.Select(b => b.ToString()).ToArray() + }, + birthDate = contact.Content.BirthDate == null + ? null + : new + { + year = contact.Content.BirthDate.Year, + month = contact.Content.BirthDate.Month, + day = contact.Content.BirthDate.Day + }, + phoneNumbers = contact.Content.PhoneNumbers.Select(p => new + { + countryPrefix = p.CountryPrefix, + number = p.Number, + label = p.Label + }).ToArray(), + addresses = contact.Content.Addresses.Select(a => new + { + isoCountryCode = a.IsoCountryCode, + city = a.City, + line1 = a.Line1, + zipCode = a.PostalCode, + label = a.Label + }).ToArray() + } + } + } + }; + + string route = $"/contacts/{contact.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs new file mode 100644 index 0000000000..5c46718243 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs @@ -0,0 +1,209 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes")] +public sealed class AddressBook : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr(IsCompound = true)] + public ContactRoot? EmergencyContact { get; set; } + + [Attr(IsCompound = true)] + public List Favorites { get; set; } = []; + + // Scalar collection of strings for text-matching filter tests (startsWith, contains, endsWith) + [Attr] + public List? Categories { get; set; } + + // Scalar collection of Guid value types for count, has, and sort=count tests + [Attr] + public List? SyncDeviceIds { get; set; } + + [HasMany] + public ISet Contacts { get; set; } = new HashSet(); +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes")] +public sealed class Contact : Identifiable +{ + [Attr(IsCompound = true)] + public ContactRoot Content { get; set; } = null!; + + [Attr] + public string? Remarks { get; set; } + + [HasOne] + public AddressBook? AddressBook { get; set; } + + [HasMany] + public ISet Notes { get; set; } = new HashSet(); +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes")] +public sealed class ContactNote : Identifiable +{ + [Attr] + public string Text { get; set; } = null!; + + [Attr] + [Required] + public int? Priority { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactRoot +{ + [Attr(IsCompound = true)] + public ContactName Name { get; set; } = null!; + + [Attr(IsCompound = true)] + public ContactCompany? Company { get; set; } + + [Attr(IsCompound = true)] + public List PhoneNumbers { get; set; } = []; + + [Attr(IsCompound = true)] + public List Addresses { get; set; } = []; + + [Attr(IsCompound = true)] + public ContactDate? BirthDate { get; set; } + + // Restricted capability: AllowView blocked (never serialized to clients) + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowView)] + public string? SecurityPin { get; set; } + + // Scalar collection with nullable elements (int?) and collection-level nullability + [Attr] + public List? Ratings { get; set; } + + [Attr(IsCompound = true)] + public List> EmailAddresses { get; set; } = []; + + [Attr(IsCompound = true)] + public List> Relations { get; set; } = []; + + // Generic compound type with DateOnly value type + [Attr(IsCompound = true)] + public List> ImportantDates { get; set; } = []; +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactName +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string? MiddleName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + // Calculated read-only property (get-only; no setter) + [Attr] + public string DisplayName => + string.Join(' ', new[] + { + FirstName, + MiddleName, + LastName + }.Where(s => !string.IsNullOrEmpty(s))); + + // Restricted capability: AllowCreate blocked (POST returns 422, PATCH allowed) + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] + public string? NickName { get; set; } + + // Restricted capability: AllowChange blocked (POST allowed, PATCH returns 422) + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] + public string? MaidenName { get; set; } + + // Restricted capability: AllowView blocked (never returned in responses) + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowView)] + public string? SecretAlias { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactCompany +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public string? Department { get; set; } + + // Scalar collection of TimeSpan value types inside a JSON-mapped complex type + [Attr] + public List BusinessHours { get; set; } = []; +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactAddress +{ + [Attr] + public string IsoCountryCode { get; set; } = null!; + + [Attr] + public string City { get; set; } = null!; + + [Attr] + public string? Line1 { get; set; } + + // Custom public name "zipCode"; JsonPropertyName is neither needed nor used + [Attr(PublicName = "zipCode")] + public string? PostalCode { get; set; } + + [Attr] + public string? Label { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactPhoneNumber +{ + [Attr] + public string? CountryPrefix { get; set; } + + // [Required] attribute on collection element to test write-side ModelState validation + [Attr] + [Required] + public string Number { get; set; } = null!; + + [Attr] + public string? Label { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactDate +{ + [Attr] + [Range(1800, 2500)] + public ushort? Year { get; set; } + + [Attr] + [Required] + [Range(1, 12)] + public byte? Month { get; set; } + + [Attr] + [Required] + [Range(1, 31)] + public byte? Day { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class LabeledValue +{ + [Attr] + public T Value { get; set; } = default!; + + [Attr] + public string? Label { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Endpoints/MinimalApis/MinimalApiTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Endpoints/MinimalApis/MinimalApiTests.cs index bd55841d64..01077c54db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Endpoints/MinimalApis/MinimalApiTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Endpoints/MinimalApis/MinimalApiTests.cs @@ -68,7 +68,8 @@ public async Task Cannot_send_email_with_invalid_addresses() // Assert response.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - response.Content.Headers.ContentType?.MediaType.Should().Be("application/problem+json"); + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType.MediaType.Should().Be("application/problem+json"); var problemDetails = await response.Content.ReadFromJsonAsync(); problemDetails.Should().NotBeNull(); @@ -134,7 +135,8 @@ public async Task Cannot_get_sent_emails_in_future() // Assert response.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - response.Content.Headers.ContentType?.MediaType.Should().Be("application/problem+json"); + response.Content.Headers.ContentType.Should().NotBeNull(); + response.Content.Headers.ContentType.MediaType.Should().Be("application/problem+json"); var problemDetails = await response.Content.ReadFromJsonAsync(); problemDetails.Should().NotBeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index b58c446ced..85b9f3afdc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -11,14 +11,23 @@ public sealed class Blog : Identifiable [Attr] public string Title { get; set; } = null!; + [Attr] + public string? Description { get; set; } + [Attr] public string PlatformName { get; set; } = null!; + [Attr] + public IList? Tags { get; set; } = new List(); + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); public bool IsPublished { get; set; } + [Attr] + public int? ShareCount { get; set; } + [HasMany] public IList Posts { get; set; } = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index f0382f90a4..a4d4cd3125 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -12,7 +12,7 @@ public sealed class BlogPost : Identifiable public string Caption { get; set; } = null!; [Attr] - public string Url { get; set; } = null!; + public string? Url { get; set; } [HasOne] public WebAccount? Author { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs index 77ff6f32cf..fc759726fb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs @@ -12,9 +12,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.D /// /// decrypt(owner.lastName) /// +/// , or: +/// +/// decrypt(_) +/// /// . /// -internal sealed class DecryptExpression(ResourceFieldChainExpression targetAttribute) : FunctionExpression +internal sealed class DecryptExpression(QueryExpression target) : FunctionExpression { public const string Keyword = "decrypt"; @@ -24,9 +28,9 @@ internal sealed class DecryptExpression(ResourceFieldChainExpression targetAttri public override Type ReturnType { get; } = typeof(string); /// - /// The string attribute to decrypt. Chain format: an optional list of to-one relationships, followed by an attribute. + /// The string attribute or scalar collection element to decrypt. /// - public ResourceFieldChainExpression TargetAttribute { get; } = targetAttribute; + public QueryExpression Target { get; } = target; public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { @@ -49,7 +53,7 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); + builder.Append(toFullString ? Target.ToFullString() : Target.ToString()); builder.Append(')'); return builder.ToString(); @@ -69,11 +73,11 @@ public override bool Equals(object? obj) var other = (DecryptExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute); + return Target.Equals(other.Target); } public override int GetHashCode() { - return TargetAttribute.GetHashCode(); + return Target.GetHashCode(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs index 06fb1b5068..d2f8363572 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs @@ -38,7 +38,9 @@ public DecryptFilterParseTests() [InlineData("filter", "equals(decrypt(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] [InlineData("filter", "equals(decrypt(^null)", "Field name expected.")] [InlineData("filter", "equals(decrypt(title)^)", ", expected.")] - [InlineData("filter", "equals(decrypt(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + [InlineData("filter", "equals(decrypt(owner.preferences.^useDarkTheme)", "Attribute 'useDarkTheme' is of type 'Boolean' while type 'String' was expected.")] + [InlineData("filter", "has(tags,equals(decrypt(^some),'secret'))", "_ expected.")] + [InlineData("filter", "has(tags,equals(decrypt(^),'secret'))", "_ expected.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -68,15 +70,16 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [InlineData("filter", "any(decrypt(title),'x','y')", null)] [InlineData("filter", "contains(decrypt(owner.userName),'secret')", null)] [InlineData("filter", "or(equals(decrypt(title),'one'),equals(decrypt(platformName),'two'))", null)] + [InlineData("filter", "has(tags,equals(decrypt(_),'secret'))", null)] + [InlineData("filter", "has(tags,startsWith(decrypt(_),'sec'))", null)] [InlineData("filter[posts]", "equals(decrypt(author.userName),'secret')", "posts")] public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) { // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs index 7fbc3ed77b..ff37256488 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings.FieldChains; @@ -34,21 +35,53 @@ private DecryptExpression ParseDecrypt() EatText(DecryptExpression.Keyword); EatSingleCharacterToken(TokenKind.OpenParen); + QueryExpression target = CurrentScope switch + { + ScalarCollectionElementFilterScope collectionScope => ParseScalarCollectionElement(collectionScope), + AttributeContainerFilterScope containerScope => ParseTargetAttribute(containerScope), + _ => throw new UnreachableException() + }; + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new DecryptExpression(target); + } + + private ScalarCollectionElementValueExpression ParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope) + { + int position = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue == null) + { + throw new QueryParseException(QueryParseChoices.Underscore, position); + } + + if (collectionScope.ElementType != typeof(string)) + { + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + containerScope.Container); 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); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(attribute.Property.PropertyType); + throw new QueryParseException($"Attribute '{attribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.", position); } - EatSingleCharacterToken(TokenKind.CloseParen); - - return new DecryptExpression(targetAttributeChain); + return targetAttributeChain; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs index a641526fcd..7673af4323 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs @@ -132,4 +132,43 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); } + + [Fact] + public async Task Can_filter_on_encrypted_column_in_scalar_collection_elements() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Tags = + [ + Convert.ToBase64String("public"u8), + Convert.ToBase64String("normal"u8) + ]; + + blogs[1].Tags = + [ + Convert.ToBase64String("public"u8), + Convert.ToBase64String("secret"u8) + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.DeclareDecryptFunctionAsync(); + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=has(tags,equals(decrypt(_),'secret'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs index 87a08c47de..a2c87ece3a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs @@ -18,7 +18,7 @@ public override Expression DefaultVisit(QueryExpression expression, QueryClauseB private MethodCallExpression VisitDecrypt(DecryptExpression expression, QueryClauseBuilderContext context) { - Expression propertyAccess = Visit(expression.TargetAttribute, context); + Expression propertyAccess = Visit(expression.Target, context); return Expression.Call(null, DatabaseFunctionStub.DecryptMethod, propertyAccess); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs index a0d3de5593..3d44589e70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs @@ -4,8 +4,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; /// -/// This expression tests if the value of a JSON:API attribute is upper case. It represents the "isUpperCase" filter function, resulting from text such -/// as: +/// This expression tests if the value of a JSON:API attribute or scalar collection element is upper case. It represents the "isUpperCase" filter +/// function, resulting from text such as: /// /// isUpperCase(title) /// @@ -13,23 +13,20 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.I /// /// isUpperCase(owner.lastName) /// +/// , or: +/// +/// isUpperCase(_) +/// /// . /// -internal sealed class IsUpperCaseExpression : FilterExpression +internal sealed class IsUpperCaseExpression(QueryExpression target) : 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. + /// The string attribute or scalar collection element whose value to inspect. /// - public ResourceFieldChainExpression TargetAttribute { get; } - - public IsUpperCaseExpression(ResourceFieldChainExpression targetAttribute) - { - ArgumentNullException.ThrowIfNull(targetAttribute); - - TargetAttribute = targetAttribute; - } + public QueryExpression Target { get; } = target; public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { @@ -52,7 +49,7 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); + builder.Append(toFullString ? Target.ToFullString() : Target.ToString()); builder.Append(')'); return builder.ToString(); @@ -72,11 +69,11 @@ public override bool Equals(object? obj) var other = (IsUpperCaseExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute); + return Target.Equals(other.Target); } public override int GetHashCode() { - return TargetAttribute.GetHashCode(); + return Target.GetHashCode(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs index 05265f3e46..a4f504c5cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -38,7 +38,9 @@ public IsUpperCaseFilterParseTests() [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.")] + [InlineData("filter", "isUpperCase(owner.preferences.^useDarkTheme)", "Attribute 'useDarkTheme' is of type 'Boolean' while type 'String' was expected.")] + [InlineData("filter", "has(tags,isUpperCase(^some))", "_ expected.")] + [InlineData("filter", "has(tags,isUpperCase(^))", "_ expected.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -66,15 +68,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [InlineData("filter", "isUpperCase(owner.userName)", null)] [InlineData("filter", "has(posts,isUpperCase(author.userName))", null)] [InlineData("filter", "or(isUpperCase(title),isUpperCase(platformName))", null)] + [InlineData("filter", "has(tags,isUpperCase(_))", 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 + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index 99c5b30bc2..0cf91f3d34 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings.FieldChains; @@ -24,21 +25,53 @@ private IsUpperCaseExpression ParseIsUpperCase() EatText(IsUpperCaseExpression.Keyword); EatSingleCharacterToken(TokenKind.OpenParen); + QueryExpression target = CurrentScope switch + { + ScalarCollectionElementFilterScope collectionScope => ParseScalarCollectionElement(collectionScope), + AttributeContainerFilterScope containerScope => ParseTargetAttribute(containerScope), + _ => throw new UnreachableException() + }; + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsUpperCaseExpression(target); + } + + private ScalarCollectionElementValueExpression ParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope) + { + int position = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue == null) + { + throw new QueryParseException(QueryParseChoices.Underscore, position); + } + + if (collectionScope.ElementType != typeof(string)) + { + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + containerScope.Container); 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); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(attribute.Property.PropertyType); + throw new QueryParseException($"Attribute '{attribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.", position); } - EatSingleCharacterToken(TokenKind.CloseParen); - - return new IsUpperCaseExpression(targetAttributeChain); + return targetAttributeChain; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs index 3b2f7e6314..ff012e4ea7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -64,16 +64,16 @@ public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint blog.Posts = _fakers.BlogPost.GenerateList(4); blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant(); - blog.Posts[0].Url = blog.Posts[0].Url.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[1].Url = blog.Posts[1].Url!.ToLowerInvariant(); blog.Posts[2].Caption = blog.Posts[1].Caption.ToLowerInvariant(); - blog.Posts[2].Url = blog.Posts[1].Url.ToUpperInvariant(); + blog.Posts[2].Url = blog.Posts[1].Url!.ToUpperInvariant(); blog.Posts[3].Caption = blog.Posts[2].Caption.ToLowerInvariant(); - blog.Posts[3].Url = blog.Posts[2].Url.ToLowerInvariant(); + blog.Posts[3].Url = blog.Posts[2].Url!.ToLowerInvariant(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -129,4 +129,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); } + + [Fact] + public async Task Can_filter_casing_in_scalar_collection_elements() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Tags = + [ + "csharp", + "dotnet" + ]; + + blogs[1].Tags = + [ + "csharp", + "DOTNET" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=has(tags,isUpperCase(_))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs index 4968392ee4..ee0e20e08d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs @@ -9,6 +9,13 @@ internal sealed class IsUpperCaseWhereClauseBuilder : WhereClauseBuilder { private static readonly MethodInfo ToUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!; + private static readonly MethodInfo CompareMethod = typeof(string).GetMethod("Compare", [ + typeof(string), + typeof(string) + ])!; + + private static readonly ConstantExpression ZeroConstantExpression = Expression.Constant(0); + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) { if (expression is IsUpperCaseExpression isUpperCaseExpression) @@ -21,9 +28,16 @@ public override Expression DefaultVisit(QueryExpression expression, QueryClauseB private BinaryExpression VisitIsUpperCase(IsUpperCaseExpression expression, QueryClauseBuilderContext context) { - Expression propertyAccess = Visit(expression.TargetAttribute, context); + Expression propertyAccess = Visit(expression.Target, context); MethodCallExpression toUpperMethodCall = Expression.Call(propertyAccess, ToUpperMethod); + if (expression.Target is ScalarCollectionElementValueExpression) + { + // Workaround for https://github.com/dotnet/efcore/issues/39351. + MethodCallExpression compareCall = Expression.Call(CompareMethod, propertyAccess, toUpperMethodCall); + return Expression.Equal(compareCall, ZeroConstantExpression); + } + 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 index f7163fc096..32defe6747 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs @@ -4,7 +4,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; /// -/// This expression determines the string length of a JSON:API attribute. It represents the "length" function, resulting from text such as: +/// This expression determines the string length of a JSON:API attribute or scalar collection element. It represents the "length" function, resulting +/// from text such as: /// /// length(title) /// @@ -12,29 +13,26 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.S /// /// length(owner.lastName) /// +/// , or: +/// +/// length(_) +/// /// . /// -internal sealed class LengthExpression : FunctionExpression +internal sealed class LengthExpression(QueryExpression target) : 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. + /// The string attribute or scalar collection element whose length to determine. /// - public ResourceFieldChainExpression TargetAttribute { get; } + public QueryExpression Target { get; } = target; /// /// The CLR type this function returns, which is always . /// public override Type ReturnType { get; } = typeof(int); - public LengthExpression(ResourceFieldChainExpression targetAttribute) - { - ArgumentNullException.ThrowIfNull(targetAttribute); - - TargetAttribute = targetAttribute; - } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.DefaultVisit(this, argument); @@ -56,7 +54,7 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); + builder.Append(toFullString ? Target.ToFullString() : Target.ToString()); builder.Append(')'); return builder.ToString(); @@ -76,11 +74,11 @@ public override bool Equals(object? obj) var other = (LengthExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute); + return Target.Equals(other.Target); } public override int GetHashCode() { - return TargetAttribute.GetHashCode(); + return Target.GetHashCode(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs index 92c104f5a4..11127eb2f3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -38,7 +38,9 @@ public LengthFilterParseTests() [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.")] + [InlineData("filter", "equals(length(owner.preferences.^useDarkTheme)", "Attribute 'useDarkTheme' is of type 'Boolean' while type 'String' was expected.")] + [InlineData("filter", "has(tags,equals(length(^some),'1'))", "_ expected.")] + [InlineData("filter", "has(tags,equals(length(^),'1'))", "_ expected.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -66,15 +68,16 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [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", "has(tags,equals(length(_),'1'))", null)] + [InlineData("filter", "has(tags,greaterThan(length(_),'5'))", 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 + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index f787319373..5dc534d9d2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings.FieldChains; @@ -34,21 +35,53 @@ private LengthExpression ParseLength() EatText(LengthExpression.Keyword); EatSingleCharacterToken(TokenKind.OpenParen); + QueryExpression target = CurrentScope switch + { + ScalarCollectionElementFilterScope collectionScope => ParseScalarCollectionElement(collectionScope), + AttributeContainerFilterScope containerScope => ParseTargetAttribute(containerScope), + _ => throw new UnreachableException() + }; + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(target); + } + + private ScalarCollectionElementValueExpression ParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope) + { + int position = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue == null) + { + throw new QueryParseException(QueryParseChoices.Underscore, position); + } + + if (collectionScope.ElementType != typeof(string)) + { + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + containerScope.Container); 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); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(attribute.Property.PropertyType); + throw new QueryParseException($"Attribute '{attribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.", position); } - EatSingleCharacterToken(TokenKind.CloseParen); - - return new LengthExpression(targetAttributeChain); + return targetAttributeChain; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs index da5091eed5..c41378a16f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs @@ -126,4 +126,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); } + + [Fact] + public async Task Can_filter_length_in_scalar_collection_elements() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Tags = + [ + "csharp", + "dotnet" + ]; + + blogs[1].Tags = + [ + "csharp", + "architecture" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=has(tags,greaterThan(length(_),'6'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs index bb530a37b8..75cef641f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs @@ -21,7 +21,7 @@ public override Expression DefaultVisit(QueryExpression expression, QueryClauseB private MemberExpression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) { - Expression propertyAccess = Visit(expression.TargetAttribute, context); + Expression propertyAccess = Visit(expression.Target, 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 index 9e55ecb901..c18fff3bfb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs @@ -34,7 +34,7 @@ public LengthSortParseTests() [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.")] + [InlineData("sort", "length(owner.preferences.^useDarkTheme)", "Attribute 'useDarkTheme' is of type 'Boolean' while type 'String' was expected.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -67,9 +67,8 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs index 6b24d4d4bc..0a514364d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -2,6 +2,7 @@ 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; @@ -35,15 +36,16 @@ private LengthExpression ParseLength(ResourceType resourceType) int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, - FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, resourceType, + FieldChainPatternMatchOptions.AllowDerivedTypes); 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); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(attribute.Property.PropertyType); + throw new QueryParseException($"Attribute '{attribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.", position); } EatSingleCharacterToken(TokenKind.CloseParen); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs index 282b3416b4..344ae78230 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs @@ -21,7 +21,7 @@ public override Expression DefaultVisit(QueryExpression expression, QueryClauseB private MemberExpression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) { - Expression propertyAccess = Visit(expression.TargetAttribute, context); + Expression propertyAccess = Visit(expression.Target, context); return Expression.Property(propertyAccess, LengthPropertyGetter); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs index 853ac7ac4d..97596ea84e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -30,11 +30,11 @@ public SumFilterParseTests() [Theory] [InlineData("filter", "equals(sum^", "( expected.")] - [InlineData("filter", "equals(sum(^", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^", "To-many relationship name 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(^)", "To-many relationship name expected.")] + [InlineData("filter", "equals(sum(^'a')", "To-many relationship name expected.")] + [InlineData("filter", "equals(sum(^null)", "To-many relationship name 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. " + @@ -42,8 +42,8 @@ public SumFilterParseTests() [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.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute. " + + "To-one relationship or non-collection 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) @@ -77,9 +77,8 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index cb33eaf030..c2c73878a1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Numerics; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; @@ -34,31 +35,47 @@ protected override FunctionExpression ParseFunction() private SumExpression ParseSum() { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + int elementPosition = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'sum' cannot be used on scalar collection elements.", elementPosition); + } + EatText(SumExpression.Keyword); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, "To-many relationship expected."); + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && !IsNullConstantToken(nextToken)) + { + ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, containerScope.Container); + + EatSingleCharacterToken(TokenKind.Comma); - EatSingleCharacterToken(TokenKind.Comma); + QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain); - QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain); + EatSingleCharacterToken(TokenKind.CloseParen); - EatSingleCharacterToken(TokenKind.CloseParen); + return new SumExpression(targetToManyRelationshipChain, selector); + } - return new SumExpression(targetToManyRelationshipChain, selector); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.ToManyRelationshipName, position); } private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain) { var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); - using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + using IDisposable scope = InScopeOfContainer(toManyRelationship.RightType); return ParseSumSelector(); } private QueryExpression ParseSumSelector() { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + throw new UnreachableException(); + } + int position = GetNextTokenPositionOrEnd(); if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) @@ -73,8 +90,7 @@ private QueryExpression ParseSumSelector() return function; } - ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, null); + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, containerScope.Container); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs index 0f25661029..ea44d4d3b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs @@ -117,7 +117,7 @@ public async Task Can_filter_sum_in_included_resources() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.Add(blog); await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs index 81d80f64b6..639221eeba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs @@ -56,7 +56,7 @@ private LiteralConstantExpression ParseTimeSpanConstant() } } - throw new QueryParseException("Time offset between quotes expected.", position); + throw new QueryParseException(QueryParseChoices.None, position, "Time offset between quotes"); } private static TimeSpan ConvertStringToTimeSpan(string value, int position) @@ -67,7 +67,7 @@ private static TimeSpan ConvertStringToTimeSpan(string value, int position) } catch (FormatException exception) { - throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{nameof(TimeSpan)}'.", position, exception); + throw new QueryParseException($"Failed to convert '{value}' to type '{nameof(TimeSpan)}'.", position, exception); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs index ed90876d91..0c07a3b256 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -122,7 +122,7 @@ public async Task Cannot_filter_comparison_on_invalid_relative_time() 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.Detail.Should().Be($"Failed to convert '*' to type 'TimeSpan'. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -191,7 +191,7 @@ public async Task Cannot_filter_text_match_on_relative_time() 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.Detail.Should().Be($"Attribute 'remindsAt' is of type 'DateTime' while type 'String' was expected. {parameterValue}"); error.Source.Should().NotBeNull(); 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 934235e608..8e85c6b46d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -11,7 +11,7 @@ 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 const string CollectionErrorMessage = "Query string parameter 'filter' can only be used on a collection of resources."; private readonly IntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 48487b2cc9..c2e34eb640 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -716,7 +716,7 @@ 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. {parameterValue}"); + error.Detail.Should().Be($"Attribute 'someInt32' is of type 'Int32' while type 'String' was expected. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -739,7 +739,7 @@ public async Task Cannot_filter_text_match_on_nested_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. {parameterValue}"); + error.Detail.Should().Be($"Attribute 'someInt32' is of type 'Int32' while type 'String' was expected. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -945,7 +945,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'. {parameterValue}"); + error.Detail.Should().Be($"Failed to convert 'ABC' to type 'Int32'. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index ca403cfb60..026449e695 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -19,6 +19,8 @@ public FilterTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); + testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.App.Services.GetRequiredService(); options.EnableLegacyFilterNotation = false; @@ -117,7 +119,7 @@ public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() } [Fact] - public async Task Can_filter_on_ID() + public async Task Can_filter_equals_on_ID() { // Arrange List accounts = _fakers.WebAccount.GenerateList(2); @@ -141,4 +143,410 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[0].StringId); responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(accounts[0].UserName); } + + [Fact] + public async Task Can_filter_equals_null_on_scalar_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Description = null; + blogs[1].Description = "Some text"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(description,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_scalar_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Description = null; + blogs[1].Description = "Some text"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=not(equals(description,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_equals_null_on_to_one_relationship() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Owner = null; + blogs[1].Owner = _fakers.WebAccount.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(owner,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_to_one_relationship() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Owner = null; + blogs[1].Owner = _fakers.WebAccount.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=not(equals(owner,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_equals_null_on_collection_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Tags = null; + + blogs[1].Tags = + [ + "news", + "tech" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(tags,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_collection_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Tags = null; + + blogs[1].Tags = + [ + "news", + "tech" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=not(equals(tags,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_equals_null_on_element_in_scalar_collection_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Tags = + [ + "news", + null! + ]; + + blogs[1].Tags = + [ + "news", + "tech" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=has(tags,equals(_,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_element_in_scalar_collection_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Tags = + [ + "news", + null! + ]; + + blogs[1].Tags = + [ + "news", + "tech" + ]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=has(tags,not(equals(_,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + } + + [Fact] + public async Task Can_filter_equals_null_on_nullable_value_type_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].ShareCount = null; + blogs[1].ShareCount = 123; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(shareCount,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_nullable_value_type_attribute() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].ShareCount = null; + blogs[1].ShareCount = 123; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=not(equals(shareCount,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_equals_null_on_non_nullable_scalar_value_type() + { + // Arrange + List calendars = _fakers.Calendar.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Calendars.AddRange(calendars); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/calendars?filter=equals(showWeekNumbers,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().BeEmpty(); + } + + [Fact] + public async Task Can_filter_not_equals_null_on_non_nullable_scalar_value_type() + { + // Arrange + List calendars = _fakers.Calendar.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Calendars.AddRange(calendars); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/calendars?filter=not(equals(showWeekNumbers,null))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + } + + [Fact] + public async Task Can_filter_equality_between_to_one_relationships() + { + // Arrange + List accounts = _fakers.WebAccount.GenerateList(2); + List posts = _fakers.BlogPost.GenerateList(2); + + posts[0].Author = accounts[0]; + posts[0].Reviewer = accounts[0]; + + posts[1].Author = accounts[0]; + posts[1].Reviewer = accounts[1]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=equals(author,reviewer)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[0].StringId); + } + + [Fact] + public async Task Can_filter_in_scope_of_relationship_with_blocked_inclusion() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(2); + blog.Posts[0].Parent = blog; + blog.Posts[1].Parent = blog; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogPosts?filter[parent.posts]=equals(id,'{blog.Posts[1].StringId}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 112d2b78a1..023b832332 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -913,7 +913,7 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() 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 the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); + error.Detail.Should().Be($"Including relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -936,7 +936,7 @@ 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("The specified include is invalid."); - error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); + error.Detail.Should().Be($"Including relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -1109,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. {parameterValue}"); + error.Detail.Should().Be($"Including 'posts.comments' has a depth of 2, which exceeds the maximum inclusion depth of 1. {parameterValue}"); error.Source.Should().NotBeNull(); 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 9fd34c7380..d466d01f09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -12,7 +12,6 @@ public sealed class PaginationWithTotalCountTests : IClassFixture, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -87,10 +86,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); + const string message = "Query string parameter 'page[number]' can only be used on a collection of resources."; + ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[number]"); + error.Detail.Should().Be($"{message} Failed at position 1: ^page[number]"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -184,10 +185,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); + const string message = "Query string parameter 'page[size]' can only be used on a collection of resources."; + ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified pagination is invalid."); - error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[size]"); + error.Detail.Should().Be($"{message} Failed at position 1: ^page[size]"); error.Source.Should().NotBeNull(); 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 9ed8ab0436..6ee6351b62 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -73,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}. {parameterValue}"); + error.Detail.Should().Be($"Page number cannot be greater than {MaximumPageNumber}. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -148,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}. {parameterValue}"); + error.Detail.Should().Be($"Page size cannot be greater than {MaximumPageSize}. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 36e5e454e7..3347432786 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -11,7 +11,11 @@ internal sealed class QueryStringFakers private readonly Lazy> _lazyBlogFaker = new(() => new Faker() .MakeDeterministic() .RuleFor(blog => blog.Title, faker => faker.Lorem.Word()) - .RuleFor(blog => blog.PlatformName, faker => faker.Company.CompanyName())); + .RuleFor(blog => blog.Description, faker => faker.Lorem.Sentence()) + .RuleFor(blog => blog.PlatformName, faker => faker.Company.CompanyName()) + .RuleFor(blog => blog.Tags, faker => faker.Lorem.Words(2).ToList()) + .RuleFor(blog => blog.IsPublished, faker => faker.Random.Bool()) + .RuleFor(blog => blog.ShareCount, faker => faker.Random.Int(1, 100))); private readonly Lazy> _lazyBlogPostFaker = new(() => new Faker() .MakeDeterministic() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs index 3a79df9fcc..08c29fcf8b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -100,7 +100,7 @@ public async Task Cannot_use_empty_query_string_parameter_value(string parameter ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Missing query string parameter value."); - error.Detail.Should().Be($"Missing value for '{parameterName}' query string parameter."); + error.Detail.Should().Be($"Parameter '{parameterName}' cannot have an empty value."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be(parameterName); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs index de556d5f9d..36202052aa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Sorting; public sealed class SortTests : 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 const string CollectionErrorMessage = "Query string parameter 'sort' can only be used on a collection of resources."; private readonly IntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -299,6 +299,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[2].Id.Should().Be(post.Labels.ElementAt(2).StringId); } + [Fact] + public async Task Can_sort_in_scope_of_relationship_with_blocked_inclusion() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?sort[parent.posts]=-caption"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + } + [Fact] public async Task Can_sort_on_multiple_fields_in_multiple_scopes() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 5c9b9c6f3e..4e769f769e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -739,7 +739,7 @@ public async Task Cannot_select_attribute_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified fieldset is invalid."); - error.Detail.Should().Be($"Retrieving the attribute 'password' is not allowed. {parameterValue}"); + error.Detail.Should().Be($"Retrieving attribute 'password' is not allowed. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("fields[webAccounts]"); } @@ -764,7 +764,7 @@ public async Task Cannot_select_ToOne_relationship_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified fieldset is invalid."); - error.Detail.Should().Be($"Retrieving the relationship 'person' is not allowed. {parameterValue}"); + error.Detail.Should().Be($"Retrieving relationship 'person' is not allowed. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("fields[webAccounts]"); } @@ -789,7 +789,7 @@ public async Task Cannot_select_ToMany_relationship_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified fieldset is invalid."); - error.Detail.Should().Be($"Retrieving the relationship 'appointments' is not allowed. {parameterValue}"); + error.Detail.Should().Be($"Retrieving relationship 'appointments' is not allowed. {parameterValue}"); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("fields[calendars]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs index c32431ec84..5564515c9b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs @@ -15,7 +15,7 @@ public sealed class WebAccount : Identifiable public string Password { get; set; } = null!; [Attr] - public string DisplayName { get; set; } = null!; + public string? DisplayName { get; set; } [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? DateOfBirth { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index ca1d406bb7..8031f9d0d9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -167,9 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), @@ -222,9 +220,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), @@ -384,7 +380,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), @@ -430,13 +425,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter) @@ -481,7 +472,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), @@ -532,7 +522,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter) @@ -572,13 +561,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter) }, options => options.WithStrictOrdering()); @@ -622,13 +607,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Constellation), ResourceDefinitionExtensibilityPoints.OnApplyFilter), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter) @@ -808,9 +789,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), @@ -849,9 +828,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), @@ -889,9 +866,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), @@ -929,9 +904,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), - (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 5415d79f14..0e7daf82ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -42,7 +42,7 @@ public async Task Cannot_sort_if_query_string_parameter_is_blocked_by_controller ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("sort"); @@ -64,7 +64,7 @@ public async Task Cannot_paginate_if_query_string_parameter_is_blocked_by_contro ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -101,7 +101,7 @@ public async Task Cannot_use_custom_query_string_parameter_if_blocked_by_control ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Title.Should().Be("Usage of query string parameter is not allowed."); error.Detail.Should().Be("The parameter 'skipCache' cannot be used at this endpoint."); error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("skipCache"); diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 788f4eabff..97980721ab 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,6 +1,7 @@ - net10.0;net9.0;net8.0 + net10.0 + $(NoWarn);JADNC001 @@ -20,6 +21,5 @@ - diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index d8eff43cba..deb8dcf0c9 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -117,7 +117,7 @@ public void MatchSucceeds(string patternText, string resourceTypeName, string fi result.IsSuccess.Should().BeTrue(); result.FieldChain[0].PublicName.Should().Be(fieldChainText); - result.FieldChain[0].Type.ClrType.Should().Be(expectedType); + result.FieldChain[0].ContainingType.ClrType.Should().Be(expectedType); result.FieldChain[0].Property.ReflectedType.Should().Be(expectedType); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 0325f9d728..ef7f757504 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -10,7 +10,7 @@ using Xunit; using Xunit.Abstractions; -// ReSharper disable InconsistentNaming +// ReSharper disable StringLiteralTypo namespace JsonApiDotNetCoreTests.UnitTests.FieldChains; @@ -21,6 +21,13 @@ public sealed class FieldChainPatternMatchTests : IDisposable private const string A = "name"; private const string O = "parent"; private const string M = "children"; + private const string C = "details"; + private const string L = "tags"; + private const string Q = "items"; + private const string A2 = "title"; + private const string C2 = "nested"; + private const string A3 = "description"; + private const string L2 = "numbers"; private readonly LoggerFactory _loggerFactory; private readonly ResourceType _resourceType; @@ -46,7 +53,11 @@ public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) [InlineData("O", O)] [InlineData("R", O)] [InlineData("R", M)] + [InlineData("S", A)] [InlineData("A", A)] + [InlineData("A", C)] + [InlineData("A", L)] + [InlineData("A", Q)] [InlineData("F", A)] [InlineData("F", O)] [InlineData("F", M)] @@ -91,6 +102,43 @@ public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) // Field chains [InlineData("MA", $"{M}.{A}")] [InlineData("MOA", $"{M}.{O}.{A}")] + // Compound and collection field types + [InlineData("C", C)] + [InlineData("L", L)] + [InlineData("Q", Q)] + [InlineData("F", C)] + [InlineData("F", L)] + [InlineData("F", Q)] + [InlineData("CA", $"{C}.{A2}")] + [InlineData("CCA", $"{C}.{C2}.{A3}")] + [InlineData("OCA", $"{O}.{C}.{A2}")] + [InlineData("OCCA", $"{O}.{C}.{C2}.{A3}")] + [InlineData("CL", $"{C}.{L2}")] + [InlineData("CQ", $"{C}.{Q}")] + // Compound character classes and quantifiers + [InlineData("[OC]", O)] + [InlineData("[OC]", C)] + [InlineData("[OC]*A", A)] + [InlineData("[OC]*A", $"{O}.{A}")] + [InlineData("[OC]*A", $"{C}.{A2}")] + [InlineData("[OC]*A", $"{C}.{C2}.{A3}")] + [InlineData("[OC]*A", $"{O}.{C}.{C2}.{A3}")] + [InlineData("[MLQ]", M)] + [InlineData("[MLQ]", L)] + [InlineData("[MLQ]", Q)] + [InlineData("[OC]*[MLQ]", M)] + [InlineData("[OC]*[MLQ]", L)] + [InlineData("[OC]*[MLQ]", Q)] + [InlineData("[OC]*[MLQ]", $"{C}.{L2}")] + [InlineData("[OC]*[MLQ]", $"{C}.{Q}")] + [InlineData("[OC]*[MLQ]", $"{O}.{M}")] + [InlineData("[OC]*[MLQ]", $"{O}.{C}.{Q}")] + [InlineData("[OC]*[OAC]", O)] + [InlineData("[OC]*[OAC]", C)] + [InlineData("[OC]*[OAC]", A)] + [InlineData("[OC]*[OAC]", $"{C}.{A2}")] + [InlineData("[OC]*[OAC]", $"{C}.{C2}")] + [InlineData("[OC]*[OAC]", $"{O}.{C}.{C2}")] // Backtracking multiple times [InlineData("F*M+O+", $"{M}.{M}.{M}.{M}.{M}.{O}.{O}.{O}.{O}.{O}")] [InlineData("F+OF*A?", $"{M}.{M}.{M}.{O}.{M}.{M}.{M}.{A}")] @@ -137,6 +185,8 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("RRR", $"{M}.{O}^", $"Relationship on resource type '{T}' expected.")] [InlineData("A", "^", $"Attribute on resource type '{T}' expected.")] [InlineData("AA", $"{A}^", "Attribute expected.")] + [InlineData("S", "^", $"Scalar non-collection attribute on resource type '{T}' expected.")] + [InlineData("SS", $"{A}^", "Scalar non-collection attribute expected.")] [InlineData("F", "^", $"Field on resource type '{T}' expected.")] [InlineData("FF", $"{M}^", $"Field on resource type '{T}' expected.")] [InlineData("FFF", $"{O}.{M}^", $"Field on resource type '{T}' expected.")] @@ -387,6 +437,23 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("M+O+", $"{M}.{M}.{O}.{O}.^{A}", $"End of field chain or to-one relationship on resource type '{T}' expected.")] [InlineData("F*M+O+", $"{M}.{M}.{O}.{O}.{A}^", "Field expected.")] [InlineData("F+R*A", $"{M}.{M}.{M}.{O}.{M}.{M}.{M}.{O}^", $"Field on resource type '{T}' expected.")] + // Compound & collection mismatch + [InlineData("S", $"^{C}", $"Scalar non-collection attribute on resource type '{T}' expected.")] + [InlineData("S", $"^{L}", $"Scalar non-collection attribute on resource type '{T}' expected.")] + [InlineData("S", $"^{Q}", $"Scalar non-collection attribute on resource type '{T}' expected.")] + [InlineData("C", $"^{A}", $"Compound non-collection attribute on resource type '{T}' expected.")] + [InlineData("C", $"^{L}", $"Compound non-collection attribute on resource type '{T}' expected.")] + [InlineData("L", $"^{A}", $"Scalar collection attribute on resource type '{T}' expected.")] + [InlineData("L", $"^{Q}", $"Scalar collection attribute on resource type '{T}' expected.")] + [InlineData("Q", $"^{L}", $"Compound collection attribute on resource type '{T}' expected.")] + // Navigation on compound type + [InlineData("CS", $"{C}.^{C2}", "Scalar non-collection attribute on compound type 'RootCompound' expected.")] + [InlineData("CS", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'RootCompound'.")] + [InlineData("CCS", $"{C}.{C2}.^{X}", "Field 'unknown' does not exist on compound type 'NestedCompound'.")] + // Too much input on compound type + [InlineData("C", $"{C}.^{A2}", "End of field chain expected.")] + // Direct navigation into collection rejected + [InlineData("[OC]*S", $"{C}.^{Q}", "Non-collection attribute on compound type 'RootCompound' expected.")] public void MatchFails(string patternText, string fieldChainText, string failureMessage) { // Arrange @@ -419,5 +486,44 @@ private sealed class Resource : Identifiable [HasMany] public ISet Children { get; set; } = new HashSet(); + + [Attr(IsCompound = true)] + public RootCompound? Details { get; set; } + + [Attr] + public List Tags { get; set; } = []; + + [Attr(IsCompound = true)] + public List Items { get; set; } = []; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class RootCompound + { + [Attr] + public string? Title { get; set; } + + [Attr(IsCompound = true)] + public NestedCompound? Nested { get; set; } + + [Attr] + public List Numbers { get; set; } = []; + + [Attr(IsCompound = true)] + public List Items { get; set; } = []; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class NestedCompound + { + [Attr] + public string? Description { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ItemCompound + { + [Attr] + public string? Label { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs index d7e5c22cc1..5911e2fcaf 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -3,20 +3,34 @@ using TestBuildingBlocks; using Xunit; +// ReSharper disable StringLiteralTypo + namespace JsonApiDotNetCoreTests.UnitTests.FieldChains; public sealed class FieldChainPatternParseTests { [Theory] - [InlineData("M", "a to-many relationship")] - [InlineData("MO", "a to-many relationship, followed by a to-one relationship")] - [InlineData("[MA]", "a to-many relationship or an attribute")] + // Single segment with quantifier + [InlineData("A?", "an optional attribute")] + [InlineData("R+", "one or more relationships")] [InlineData("M*", "zero or more to-many relationships")] + [InlineData("[OC]?", "an optional to-one relationship or compound non-collection attribute")] + // Two segments + [InlineData("MO", "a to-many relationship, followed by a to-one relationship")] + [InlineData("MO+", "a to-many relationship, followed by one or more to-one relationships")] + [InlineData("MO?", "a to-many relationship, followed by an optional to-one relationship")] [InlineData("M*O", "zero or more to-many relationships, followed by a to-one relationship")] [InlineData("R*M", "zero or more relationships, followed by a to-many relationship")] - [InlineData("MO+", "a to-many relationship, followed by one or more to-one relationships")] + [InlineData("M*S", "zero or more to-many relationships, followed by a scalar non-collection attribute")] + [InlineData("C+A", "one or more compound non-collection attributes, followed by an attribute")] [InlineData("[OA]?A", "an optional to-one relationship or attribute, followed by an attribute")] [InlineData("O*[MA]", "zero or more to-one relationships, followed by a to-many relationship or an attribute")] + [InlineData("[RC]*S", "zero or more relationships or compound non-collection attributes, followed by a scalar non-collection attribute")] + [InlineData("[MC]*[MSC]", + "zero or more to-many relationships or compound non-collection attributes, followed by a to-many relationship or a non-collection attribute")] + [InlineData("[RC]*[OLQ]", "zero or more relationships or compound non-collection attributes, followed by a to-one relationship or a collection attribute")] + [InlineData("[CQ]*A", "zero or more compound attributes, followed by an attribute")] + // Three segments [InlineData("O?M+A", "an optional to-one relationship, followed by one or more to-many relationships, followed by an attribute")] public void ParseSucceeds(string patternText, string displayText) { @@ -30,16 +44,20 @@ public void ParseSucceeds(string patternText, string displayText) [Theory] [InlineData("^", "Pattern is empty.")] + [InlineData("^ ", "Unknown token ' '.")] [InlineData("^X", "Unknown token 'X'.")] [InlineData("A^X", "Unknown token 'X'.")] [InlineData("^*", "Field type or [ expected.")] + [InlineData("^]", "Field type or [ expected.")] + [InlineData("A^]", "Field type or [ expected.")] [InlineData("A+^+", "Field type or [ expected.")] [InlineData("[^", "Field type expected.")] [InlineData("[^?", "Field type expected.")] [InlineData("[^]", "Field type expected.")] + [InlineData("[^[", "Field type expected.")] [InlineData("[A^", "Field type or ] expected.")] - [InlineData("[MA^", "Field type or ] expected.")] - [InlineData("[MA^+", "Field type or ] expected.")] + [InlineData("[A^+", "Field type or ] expected.")] + [InlineData("[A^[", "Field type or ] expected.")] public void ParseFails(string patternText, string errorMessage) { // Arrange @@ -56,11 +74,128 @@ public void ParseFails(string patternText, string errorMessage) } [Theory] + [InlineData("O", "O", "a to-one relationship")] + [InlineData("[O]", "O", "a to-one relationship")] + [InlineData("M", "M", "a to-many relationship")] + [InlineData("[M]", "M", "a to-many relationship")] + [InlineData("R", "R", "a relationship")] + [InlineData("[R]", "R", "a relationship")] + [InlineData("S", "S", "a scalar non-collection attribute")] + [InlineData("[S]", "S", "a scalar non-collection attribute")] + [InlineData("C", "C", "a compound non-collection attribute")] + [InlineData("[C]", "C", "a compound non-collection attribute")] + [InlineData("L", "L", "a scalar collection attribute")] + [InlineData("[L]", "L", "a scalar collection attribute")] + [InlineData("Q", "Q", "a compound collection attribute")] + [InlineData("[Q]", "Q", "a compound collection attribute")] + [InlineData("A", "A", "an attribute")] [InlineData("[A]", "A", "an attribute")] + [InlineData("F", "F", "a field")] + [InlineData("[F]", "F", "a field")] + public void ParseSingleChoice(string patternText, string reducedText, string displayText) + { + // Act + FieldChainPattern pattern = FieldChainPattern.Parse(patternText); + + // Assert + pattern.ToString().Should().Be(reducedText); + pattern.GetDescription().Should().Be(displayText); + } + + [Theory] + // 2 tokens (10 combinations) + [InlineData("[SO]", "[OS]", "a to-one relationship or a scalar non-collection attribute")] + [InlineData("[CO]", "[OC]", "a to-one relationship or a compound non-collection attribute")] + [InlineData("[LO]", "[OL]", "a to-one relationship or a scalar collection attribute")] + [InlineData("[QO]", "[OQ]", "a to-one relationship or a compound collection attribute")] + [InlineData("[SM]", "[MS]", "a to-many relationship or a scalar non-collection attribute")] + [InlineData("[CM]", "[MC]", "a to-many relationship or a compound non-collection attribute")] + [InlineData("[LM]", "[ML]", "a to-many relationship or a scalar collection attribute")] + [InlineData("[QM]", "[MQ]", "a to-many relationship or a compound collection attribute")] + [InlineData("[QS]", "[SQ]", "a scalar non-collection attribute or a compound collection attribute")] + [InlineData("[CL]", "[LC]", "a scalar collection attribute or a compound non-collection attribute")] + // 3 tokens (20 combinations) + [InlineData("[SMO]", "[RS]", "a relationship or a scalar non-collection attribute")] + [InlineData("[CMO]", "[RC]", "a relationship or a compound non-collection attribute")] + [InlineData("[LMO]", "[RL]", "a relationship or a scalar collection attribute")] + [InlineData("[QMO]", "[RQ]", "a relationship or a compound collection attribute")] + [InlineData("[CSO]", "[OSC]", "a to-one relationship or a non-collection attribute")] + [InlineData("[LSO]", "[OSL]", "a to-one relationship or a scalar attribute")] + [InlineData("[QSO]", "[OSQ]", "a to-one relationship or a scalar non-collection attribute or a compound collection attribute")] + [InlineData("[CLO]", "[OLC]", "a to-one relationship or a scalar collection attribute or a compound non-collection attribute")] + [InlineData("[QCO]", "[OCQ]", "a to-one relationship or a compound attribute")] + [InlineData("[QLO]", "[OLQ]", "a to-one relationship or a collection attribute")] + [InlineData("[CSM]", "[MSC]", "a to-many relationship or a non-collection attribute")] + [InlineData("[LSM]", "[MSL]", "a to-many relationship or a scalar attribute")] + [InlineData("[QSM]", "[MSQ]", "a to-many relationship or a scalar non-collection attribute or a compound collection attribute")] + [InlineData("[CLM]", "[MLC]", "a to-many relationship or a scalar collection attribute or a compound non-collection attribute")] + [InlineData("[QCM]", "[MCQ]", "a to-many relationship or a compound attribute")] + [InlineData("[QLM]", "[MLQ]", "a to-many relationship or a collection attribute")] + [InlineData("[CLS]", "[SLC]", "a scalar attribute or a compound non-collection attribute")] + [InlineData("[QCS]", "[SCQ]", "a scalar non-collection attribute or a compound attribute")] + [InlineData("[QLS]", "[SLQ]", "a scalar attribute or a compound collection attribute")] + [InlineData("[QCL]", "[LCQ]", "a scalar collection attribute or a compound attribute")] + // 4 tokens (14 combinations) + [InlineData("[CSMO]", "[RSC]", "a relationship or a non-collection attribute")] + [InlineData("[LSMO]", "[RSL]", "a relationship or a scalar attribute")] + [InlineData("[QSMO]", "[RSQ]", "a relationship or a scalar non-collection attribute or a compound collection attribute")] + [InlineData("[CLMO]", "[RLC]", "a relationship or a scalar collection attribute or a compound non-collection attribute")] + [InlineData("[QCMO]", "[RCQ]", "a relationship or a compound attribute")] + [InlineData("[QLMO]", "[RLQ]", "a relationship or a collection attribute")] + [InlineData("[CLSO]", "[OSLC]", "a to-one relationship or a scalar attribute or a compound non-collection attribute")] + [InlineData("[QCSO]", "[OSCQ]", "a to-one relationship or a scalar non-collection attribute or a compound attribute")] + [InlineData("[QLSO]", "[OSLQ]", "a to-one relationship or a scalar attribute or a compound collection attribute")] + [InlineData("[QCLO]", "[OLCQ]", "a to-one relationship or a scalar collection attribute or a compound attribute")] + [InlineData("[CLSM]", "[MSLC]", "a to-many relationship or a scalar attribute or a compound non-collection attribute")] + [InlineData("[QCSM]", "[MSCQ]", "a to-many relationship or a scalar non-collection attribute or a compound attribute")] + [InlineData("[QLSM]", "[MSLQ]", "a to-many relationship or a scalar attribute or a compound collection attribute")] + [InlineData("[QCLM]", "[MLCQ]", "a to-many relationship or a scalar collection attribute or a compound attribute")] + // 5 tokens (6 combinations) + [InlineData("[CLSMO]", "[RSLC]", "a relationship or a scalar attribute or a compound non-collection attribute")] + [InlineData("[QCSMO]", "[RSCQ]", "a relationship or a scalar non-collection attribute or a compound attribute")] + [InlineData("[QLSMO]", "[RSLQ]", "a relationship or a scalar attribute or a compound collection attribute")] + [InlineData("[QCLMO]", "[RLCQ]", "a relationship or a scalar collection attribute or a compound attribute")] + [InlineData("[QCLSO]", "[OA]", "a to-one relationship or an attribute")] + [InlineData("[QCLSM]", "[MA]", "a to-many relationship or an attribute")] + public void ParseCombinesChoices(string patternText, string reducedText, string displayText) + { + // Act + FieldChainPattern pattern = FieldChainPattern.Parse(patternText); + + // Assert + pattern.ToString().Should().Be(reducedText); + pattern.GetDescription().Should().Be(displayText); + } + + [Theory] + // Unification to attribute + [InlineData("[SCLQ]", "A", "an attribute")] + [InlineData("[SQLC]", "A", "an attribute")] + [InlineData("[ASCLQ]", "A", "an attribute")] + // Unification to relationship [InlineData("[MO]", "R", "a relationship")] + [InlineData("[OM]", "R", "a relationship")] [InlineData("[MOR]", "R", "a relationship")] + // Unification to field + [InlineData("[MOSCLQ]", "F", "a field")] + [InlineData("[RA]", "F", "a field")] + [InlineData("[MOA]", "F", "a field")] + [InlineData("[RSCLQ]", "F", "a field")] + [InlineData("[MORSCLQ]", "F", "a field")] + [InlineData("[MORACLQ]", "F", "a field")] [InlineData("[MORAF]", "F", "a field")] - [InlineData("[AMAM]", "[MA]", "a to-many relationship or an attribute")] + // Unification to scalar attribute + [InlineData("[SL]", "[SL]", "a scalar attribute")] + [InlineData("[LS]", "[SL]", "a scalar attribute")] + // Unification to compound attribute + [InlineData("[CQ]", "[CQ]", "a compound attribute")] + [InlineData("[QC]", "[CQ]", "a compound attribute")] + // Unification to non-collection attribute + [InlineData("[SC]", "[SC]", "a non-collection attribute")] + [InlineData("[CS]", "[SC]", "a non-collection attribute")] + // Unification to collection attribute + [InlineData("[LQ]", "[LQ]", "a collection attribute")] + [InlineData("[QL]", "[LQ]", "a collection attribute")] public void ParseNormalizesChoices(string patternText, string reducedText, string displayText) { // Act diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 76760be7d7..429a4f189c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -85,7 +85,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso Kind = EndpointKind.Relationship, Relationship = new HasOneAttribute { - Type = exampleResourceType + ContainingType = exampleResourceType } }; @@ -358,7 +358,7 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR var relationship = new HasOneAttribute { Links = linksInRelationshipAttribute, - Type = exampleResourceType + ContainingType = exampleResourceType }; // Act diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs index 9fe5b08ec6..ff62346720 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Serialization; using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -113,11 +112,11 @@ private sealed class Parent : Identifiable [Attr(PublicName = "publicNameOfOne")] public string? One { get; set; } - [Attr(PublicName = "publicNameOfComplexObject")] - public ComplexObject? ComplexObject { get; set; } + [Attr(PublicName = "publicNameOfComplexObject", IsCompound = true)] + public ComplexRootObject? ComplexObject { get; set; } - [Attr(PublicName = "publicNameOfComplexList")] - public IList ComplexList { get; set; } = null!; + [Attr(PublicName = "publicNameOfComplexList", IsCompound = true)] + public IList ComplexList { get; set; } = null!; [HasOne(PublicName = "publicNameOfPrimaryChild")] public Child? PrimaryChild { get; set; } @@ -139,15 +138,29 @@ private sealed class Child : Identifiable } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ComplexObject + private sealed class ComplexRootObject { - [JsonPropertyName("jsonFirst")] + [Attr(PublicName = "jsonFirst")] public string? First { get; set; } - [JsonPropertyName("jsonParentObject")] - public ComplexObject? ParentObject { get; set; } + [Attr(PublicName = "jsonParentObject", IsCompound = true)] + public ComplexParentObject? ParentObject { get; set; } - [JsonPropertyName("jsonElements")] - public IList Elements { get; set; } = null!; + [Attr(PublicName = "jsonElements", IsCompound = true)] + public IList Elements { get; set; } = null!; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ComplexParentObject + { + [Attr(PublicName = "jsonFirst")] + public string? First { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ComplexElementObject + { + [Attr(PublicName = "jsonFirst")] + public string? First { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs index d182e9b60e..34857e671c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionRewriterTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.ComponentModel.Design; using FluentAssertions; using JsonApiDotNetCore.Configuration; @@ -59,15 +58,15 @@ public void VisitInclude(string expressionText, string expectedTypes) } [Theory] - [InlineData("title", "SparseFieldSet")] - [InlineData("title,posts", "SparseFieldSet")] + [InlineData("title", "SparseFieldSet,SparseFieldSetElement")] + [InlineData("title,posts", "SparseFieldSet,SparseFieldSetElement,SparseFieldSetElement")] public void VisitSparseFieldSet(string expressionText, string expectedTypes) { // Arrange var parser = new SparseFieldSetParser(); ResourceType blogType = ResourceGraph.GetResourceType(); - QueryExpression expression = parser.Parse(expressionText, blogType)!; + QueryExpression expression = parser.Parse(expressionText, blogType); var rewriter = new TestableQueryExpressionRewriter(); // Act @@ -81,36 +80,6 @@ public void VisitSparseFieldSet(string expressionText, string expectedTypes) visitedTypeNames.Should().HaveCount(expectedTypeNames.Length); } - [Fact] - public void VisitSparseFieldTable() - { - // Arrange - var parser = new SparseFieldSetParser(); - - ResourceType blogType = ResourceGraph.GetResourceType(); - ResourceType commentType = ResourceGraph.GetResourceType(); - - var sparseFieldTable = new Dictionary - { - [blogType] = parser.Parse("title,owner", blogType)!, - [commentType] = parser.Parse("text,createdAt", commentType)! - }; - - var expression = new SparseFieldTableExpression(sparseFieldTable.ToImmutableDictionary()); - var rewriter = new TestableQueryExpressionRewriter(); - - // Act - rewriter.Visit(expression, null); - - // Assert - string[] visitedTypeNames = rewriter.ExpressionsVisited.Select(queryExpression => queryExpression.GetType().Name).ToArray(); - - visitedTypeNames.Should().HaveCount(3); - visitedTypeNames[0].Should().Be("SparseFieldTableExpression"); - visitedTypeNames[1].Should().Be("SparseFieldSetExpression"); - visitedTypeNames[2].Should().Be("SparseFieldSetExpression"); - } - [Theory] [InlineData("any(userName,'A','B')", "Any,ResourceFieldChain,LiteralConstant,LiteralConstant")] [InlineData("equals(userName,null)", "Comparison,ResourceFieldChain,NullConstant")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs index 5b0ae957af..8af11939a0 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs @@ -29,6 +29,10 @@ public sealed class QueryExpressionTests TestExpressionFactory.Instance.Count(), TestExpressionFactory.Instance.Count() ], + [ + TestExpressionFactory.Instance.ScalarCollectionElementValue(), + TestExpressionFactory.Instance.ScalarCollectionElementValue() + ], [ TestExpressionFactory.Instance.Has(), TestExpressionFactory.Instance.Has() @@ -110,8 +114,8 @@ public sealed class QueryExpressionTests TestExpressionFactory.Instance.SparseFieldSet() ], [ - TestExpressionFactory.Instance.SparseFieldTable(), - TestExpressionFactory.Instance.SparseFieldTable() + TestExpressionFactory.Instance.SparseFieldSetElement(), + TestExpressionFactory.Instance.SparseFieldSetElement() ] }; @@ -162,7 +166,7 @@ public void Expressions_convert_to_same_full_string(QueryExpression left, QueryE [Theory] [MemberData(nameof(ExpressionTestData))] - public void Expressions_have_same_return_type(QueryExpression left, QueryExpression right) + public void Function_expressions_have_same_return_type(QueryExpression left, QueryExpression right) { if (left is FunctionExpression leftFunction && right is FunctionExpression rightFunction) { @@ -171,6 +175,17 @@ public void Expressions_have_same_return_type(QueryExpression left, QueryExpress } } + [Theory] + [MemberData(nameof(ExpressionTestData))] + public void Scalar_collection_expressions_have_same_return_type(QueryExpression left, QueryExpression right) + { + if (left is ScalarCollectionElementValueExpression leftElement && right is ScalarCollectionElementValueExpression rightElement) + { + // Assert + leftElement.ElementType.Should().Be(rightElement.ElementType); + } + } + [Theory] [MemberData(nameof(ExpressionTestData))] public void Expressions_can_accept_visitor(QueryExpression left, QueryExpression right) @@ -296,7 +311,6 @@ private sealed class DerivedTestResource : BaseTestResource; private sealed class TestExpressionFactory { - private readonly ResourceType _baseTestResourceType; private readonly ResourceType _derivedTestResourceType; private readonly AttrAttribute _textAttribute; private readonly RelationshipAttribute _parentRelationship; @@ -313,11 +327,11 @@ private TestExpressionFactory() builder.Add(); IResourceGraph resourceGraph = builder.Build(); - _baseTestResourceType = resourceGraph.GetResourceType(); + ResourceType baseTestResourceType = resourceGraph.GetResourceType(); _derivedTestResourceType = resourceGraph.GetResourceType(); - _textAttribute = _baseTestResourceType.GetAttributeByPropertyName(nameof(BaseTestResource.Text)); - _parentRelationship = _baseTestResourceType.GetRelationshipByPropertyName(nameof(BaseTestResource.Parent)); - _childrenRelationship = _baseTestResourceType.GetRelationshipByPropertyName(nameof(BaseTestResource.Children)); + _textAttribute = baseTestResourceType.GetAttributeByPropertyName(nameof(BaseTestResource.Text)); + _parentRelationship = baseTestResourceType.GetRelationshipByPropertyName(nameof(BaseTestResource.Parent)); + _childrenRelationship = baseTestResourceType.GetRelationshipByPropertyName(nameof(BaseTestResource.Children)); } public AnyExpression Any() @@ -335,6 +349,11 @@ public CountExpression Count() return new CountExpression(ResourceFieldChainForChildren()); } + public ScalarCollectionElementValueExpression ScalarCollectionElementValue() + { + return new ScalarCollectionElementValueExpression(typeof(string)); + } + public HasExpression Has() { return new HasExpression(ResourceFieldChainForChildren(), Comparison()); @@ -435,19 +454,14 @@ public SortExpression Sort() public SparseFieldSetExpression SparseFieldSet() { - return new SparseFieldSetExpression([ - _textAttribute, - _childrenRelationship - ]); + return new SparseFieldSetExpression(ImmutableDictionary.Empty + .Add(_textAttribute, new SparseFieldSetElementExpression(_textAttribute)) + .Add(_childrenRelationship, new SparseFieldSetElementExpression(_childrenRelationship))); } - public SparseFieldTableExpression SparseFieldTable() + public SparseFieldSetElementExpression SparseFieldSetElement() { - return new SparseFieldTableExpression(new Dictionary - { - [_baseTestResourceType] = SparseFieldSet(), - [_derivedTestResourceType] = SparseFieldSet() - }.ToImmutableDictionary()); + return new SparseFieldSetElementExpression(_textAttribute); } private static IQueryable TestQueryableHandler(IQueryable source, StringValues parameterValue) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs new file mode 100644 index 0000000000..e04307ca67 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs @@ -0,0 +1,365 @@ +using System.Collections.Immutable; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Queries; + +public sealed class SparseFieldSetExpressionExtensionsTests +{ + private readonly IResourceGraph _resourceGraph; + + public SparseFieldSetExpressionExtensionsTests() + { + var options = new JsonApiOptions(); + _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + } + + [Fact] + public void Including_preserves_nested_field_elements() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + CompoundType contentType = contentAttr.CompoundType!; + AttrAttribute nameAttr = contentType.GetAttributeByPropertyName(nameof(ContactContent.Name)); + CompoundType nameType = nameAttr.CompoundType!; + AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); + + var lastNameElement = new SparseFieldSetElementExpression(lastNameAttr); + + var nameElement = new SparseFieldSetElementExpression(nameAttr, + ImmutableDictionary.Empty.Add(lastNameAttr, lastNameElement)); + + var contentElement = new SparseFieldSetElementExpression(contentAttr, + ImmutableDictionary.Empty.Add(nameAttr, nameElement)); + + var initialSet = + new SparseFieldSetExpression(ImmutableDictionary.Empty.Add(contentAttr, contentElement)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Remarks, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(2); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + updatedSet.Elements.Keys.Should().Contain([ + contentAttr, + remarksAttr + ]); + + updatedSet.ToString().Should().Be("content.name.lastName,remarks"); + } + + [Fact] + public void Excluding_removes_matching_field_and_preserves_nested_chains() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + CompoundType contentType = contentAttr.CompoundType!; + AttrAttribute nameAttr = contentType.GetAttributeByPropertyName(nameof(ContactContent.Name)); + CompoundType nameType = nameAttr.CompoundType!; + AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + var lastNameElement = new SparseFieldSetElementExpression(lastNameAttr); + + var nameElement = new SparseFieldSetElementExpression(nameAttr, + ImmutableDictionary.Empty.Add(lastNameAttr, lastNameElement)); + + var contentElement = new SparseFieldSetElementExpression(contentAttr, + ImmutableDictionary.Empty.Add(nameAttr, nameElement)); + + var remarksElement = new SparseFieldSetElementExpression(remarksAttr); + + var initialSet = new SparseFieldSetExpression(ImmutableDictionary.Empty + .Add(contentAttr, contentElement).Add(remarksAttr, remarksElement)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Remarks, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(1); + updatedSet.Elements.Values.Single().Field.Should().Be(contentAttr); + updatedSet.ToString().Should().Be("content.name.lastName"); + } + + [Fact] + public void Excluding_top_level_compound_removes_all_its_nested_chains() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + CompoundType contentType = contentAttr.CompoundType!; + AttrAttribute nameAttr = contentType.GetAttributeByPropertyName(nameof(ContactContent.Name)); + CompoundType nameType = nameAttr.CompoundType!; + AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + var lastNameElement = new SparseFieldSetElementExpression(lastNameAttr); + + var nameElement = new SparseFieldSetElementExpression(nameAttr, + ImmutableDictionary.Empty.Add(lastNameAttr, lastNameElement)); + + var contentElement = new SparseFieldSetElementExpression(contentAttr, + ImmutableDictionary.Empty.Add(nameAttr, nameElement)); + + var remarksElement = new SparseFieldSetElementExpression(remarksAttr); + + var initialSet = new SparseFieldSetExpression(ImmutableDictionary.Empty + .Add(contentAttr, contentElement).Add(remarksAttr, remarksElement)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Content, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(1); + updatedSet.Elements.Values.Single().Field.Should().Be(remarksAttr); + updatedSet.ToString().Should().Be("remarks"); + } + + [Fact] + public void Excluding_leaf_from_whole_compound_expands_siblings_and_excludes_leaf() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + var wholeContentElement = new SparseFieldSetElementExpression(contentAttr); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(contentAttr, wholeContentElement)); + + // Act - exclude Content.Name.LastName, which should keep Content.Name.FirstName + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.ToString().Should().Be("content.name.firstName"); + } + + [Fact] + public void Excluding_all_fields_returns_Empty_singleton() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(remarksAttr); + + // Assert + updatedSet.Should().BeSameAs(SparseFieldSetExpression.Empty); + updatedSet.Elements.Should().BeEmpty(); + } + + [Fact] + public void Including_whole_compound_when_leaf_already_selected_widens_to_whole_compound() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + SparseFieldSetExpression? initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))).Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Content, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.ToString().Should().Be("content,remarks"); + } + + [Fact] + public void Including_leaf_when_whole_compound_already_selected_is_noop() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(contentAttr, + new SparseFieldSetElementExpression(contentAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.ToString().Should().Be("content"); + } + + [Fact] + public void Including_accepts_nested_lambda_chain_and_preserves_it() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(2); + updatedSet.ToString().Should().Be("content.name.lastName,remarks"); + } + + [Fact] + public void Excluding_accepts_nested_lambda_chain() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + SparseFieldSetExpression? initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))).Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(1); + updatedSet.ToString().Should().Be("remarks"); + } + + [Fact] + public void Including_accepts_anonymous_type_with_nested_and_top_level_lambdas() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => new + { + c.Remarks, + c.Content!.Name!.LastName + }, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(2); + updatedSet.ToString().Should().Be("content.name.lastName,remarks"); + } + + [Fact] + public void Including_single_ResourceFieldAttribute_adds_field_chain() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(contentAttr); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(2); + + updatedSet.Elements.Keys.Should().Contain([ + contentAttr, + remarksAttr + ]); + + updatedSet.ToString().Should().Be("content,remarks"); + } + + [Fact] + public void Excluding_single_ResourceFieldAttribute_removes_field_chain() + { + // Arrange + ResourceType contactType = _resourceGraph.GetResourceType(); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + AttrAttribute contentAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Content)); + + var initialSet = new SparseFieldSetExpression(ImmutableDictionary.Empty + .Add(remarksAttr, new SparseFieldSetElementExpression(remarksAttr)).Add(contentAttr, new SparseFieldSetElementExpression(contentAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(remarksAttr); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet.Elements.Should().HaveCount(1); + updatedSet.Elements.Values.Single().Field.Should().Be(contentAttr); + updatedSet.ToString().Should().Be("content"); + } + + [Fact] + public void Including_or_Excluding_when_null_returns_null() + { + SparseFieldSetExpression? nullSet = null; + + SparseFieldSetExpression? includeResult = nullSet.Including(c => c.Remarks, _resourceGraph); + SparseFieldSetExpression? excludeResult = nullSet.Excluding(c => c.Remarks, _resourceGraph); + + includeResult.Should().BeNull(); + excludeResult.Should().BeNull(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class Contact : Identifiable + { + [Attr(IsCompound = true)] + public ContactContent? Content { get; set; } + + [Attr] + public string? Remarks { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ContactContent + { + [Attr(IsCompound = true)] + public PersonName? Name { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class PersonName + { + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string? LastName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs index 1f46516854..d3a794252b 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs @@ -96,16 +96,16 @@ public override QueryExpression VisitPagination(PaginationExpression expression, return base.VisitAny(expression, argument); } - public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, object? argument) + public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, object? argument) { Capture(expression); - return base.VisitSparseFieldTable(expression, argument); + return base.VisitSparseFieldSet(expression, argument); } - public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, object? argument) + public override QueryExpression VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, object? argument) { Capture(expression); - return base.VisitSparseFieldSet(expression, argument); + return base.VisitSparseFieldSetElement(expression, argument); } public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, object? argument) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index d3ea46286f..8278d9c34a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -97,43 +97,58 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe } [Theory] - [InlineData("filter[posts]", "equals(author,^'some')", "null expected.")] + [InlineData("filter[posts]", "equals(author,^'some')", "To-one relationship name or null expected.")] [InlineData("filter[posts]", "lessThan(^some,null)", "Field 'some' does not exist on resource type 'blogPosts'.")] [InlineData("filter[posts]", "lessThan(author^,null)", - "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.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute. " + + "To-one relationship or non-collection attribute on resource type 'webAccounts' expected.")] [InlineData("filter", "^ ", "Unexpected whitespace.")] [InlineData("filter", "contains(owner.displayName^ ,)", "Unexpected whitespace.")] [InlineData("filter", "contains(owner.displayName,^ )", "Unexpected whitespace.")] [InlineData("filter", "^some", "Filter function expected.")] [InlineData("filter", "equals^", "( expected.")] - [InlineData("filter", "equals^'", "Unexpected ' outside text.")] + [InlineData("filter", "equals^'", "Unexpected ' outside quoted text.")] [InlineData("filter", "equals(^", "Function or field name expected.")] [InlineData("filter", "equals(^'1'", "Function or field name expected.")] - [InlineData("filter", "equals(count(posts),^", "Function, field name or value between quotes expected.")] - [InlineData("filter", "equals(count(posts),^null)", "Function, field name or value between quotes expected.")] - [InlineData("filter", "equals(owner.^.displayName,'')", "Function or field name expected.")] - [InlineData("filter", "equals(owner.displayName.^,'')", "Function or field name expected.")] - [InlineData("filter", "equals(title,'^)", "' expected.")] - [InlineData("filter", "equals(title,null^", ") expected.")] - [InlineData("filter", "equals(^null", "Function or field name expected.")] + [InlineData("filter", "equals(count(posts),^", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "greaterThan(count(posts),^null)", "Function, field name or value between quotes expected.")] + [InlineData("filter", "greaterThan(showAdvertisements,^null)", "Function, field name or value between quotes expected.")] + [InlineData("filter", "equals(owner.^.displayName,'')", "Field name expected.")] + [InlineData("filter", "equals(owner.displayName.^,'')", "Field name expected.")] + [InlineData("filter", "equals(title,'^)", "Closing ' expected.")] [InlineData("filter", "equals(title,^(", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "equals(description,null^", ") expected.")] + [InlineData("filter", "equals(description,^(", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "equals(shareCount,null^", ") expected.")] + [InlineData("filter", "equals(shareCount,^(", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "has(tags,equals(^unknown,'work'))", "Function or _ expected.")] + [InlineData("filter", "has(tags,contains(^unknown,'work'))", "Function or _ expected.")] + [InlineData("filter", "has(tags,any(^unknown))", "Function or _ expected.")] + [InlineData("filter", "has(tags,^has(tags,equals(_,'w')))", "Function 'has' cannot be used on scalar collection elements.")] + [InlineData("filter", "has(tags,equals(^count(tags),'1'))", "Function 'count' cannot be used on scalar collection elements.")] + [InlineData("filter", "has(tags,^isType(,some))", "Function 'isType' can only be used on resource types.")] + [InlineData("filter", "equals(title,^_)", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "equals(owner,^'some')", "To-one relationship name or null expected.")] + [InlineData("filter", "equals(^posts,null)", + "Field chain on resource type 'blogs' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-one relationship or an attribute. " + + "To-one relationship or attribute on resource type 'blogs' expected.")] + [InlineData("filter", "equals(^null", "Function or field name expected.")] [InlineData("filter", "has(posts,^", "Filter function expected.")] [InlineData("filter", "contains^)", "( expected.")] [InlineData("filter", "contains(title,'a'^,'b')", ") expected.")] - [InlineData("filter", "contains(^equals(title,'x'),'a')", "Function that returns type 'String' expected.")] + [InlineData("filter", "contains(^equals(title,'x'),'a')", "Function returns type 'Boolean' while type 'String' was expected.")] [InlineData("filter", "contains(title,^null)", "Value between quotes expected.")] [InlineData("filter[posts]", "contains(author^,null)", - "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", "any(^null,'a','b')", "Field name expected.")] - [InlineData("filter", "any(^'a','b','c')", "Field name expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute. " + + "To-one relationship or non-collection attribute on resource type 'webAccounts' expected.")] + [InlineData("filter", "any(^null,'a','b')", "Function or field name expected.")] + [InlineData("filter", "any(^'a','b','c')", "Function or field name expected.")] [InlineData("filter", "any(title,'b','c',^)", "Value between quotes expected.")] - [InlineData("filter", "any(equals(title,'x'),^'b')", "Failed to convert 'b' of type 'String' to type 'Boolean'.")] + [InlineData("filter", "any(equals(title,'x'),^'b')", "Failed to convert 'b' to type 'Boolean'.")] [InlineData("filter", "any(title^)", ", expected.")] [InlineData("filter[posts]", "any(author^,'a','b')", - "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.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute. " + + "To-one relationship or non-collection attribute on resource type 'webAccounts' expected.")] [InlineData("filter", "and(^", "Filter function expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other')^", ") expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other'))^)", "End of expression expected.")] @@ -141,7 +156,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "and(^null", "Filter function expected.")] [InlineData("filter", "^expr:equals(caption,'some')", "Filter function expected.")] [InlineData("filter", "^expr:Equals(caption,'some')", "Filter function expected.")] - [InlineData("filter", "isType(^", "Relationship name or , expected.")] + [InlineData("filter", "isType(^", "To-one relationship name or , expected.")] [InlineData("filter", "isType(,^", "Resource type expected.")] [InlineData("filter[posts.contributors]", "isType(,^some)", "Resource type 'some' does not exist or does not derive from 'humans'.")] [InlineData("filter[posts.contributors]", "isType(,^humans)", "Resource type 'humans' does not exist or does not derive from 'humans'.")] @@ -188,9 +203,25 @@ public void Reader_Read_ParameterValue_Fails(string parameterName, string parame [InlineData("filter[owner.person.wife.children]", "not(equals(mother,null))", "owner.person.wife.children")] [InlineData("filter", "equals(count(posts),'1')", null)] [InlineData("filter", "equals(count(posts),count(owner.posts))", null)] + [InlineData("filter", "equals(count(posts),null)", null)] + [InlineData("filter", "equals(showAdvertisements,null)", null)] + [InlineData("filter", "equals(owner.preferences.useDarkTheme,null)", null)] [InlineData("filter", "equals(has(posts),'true')", null)] + [InlineData("filter", "equals(description,null)", null)] + [InlineData("filter", "equals(shareCount,null)", null)] + [InlineData("filter", "equals(owner,null)", null)] + [InlineData("filter", "equals(tags,null)", null)] + [InlineData("filter", "not(equals(tags,null))", null)] + [InlineData("filter", "has(tags)", null)] + [InlineData("filter", "has(tags,equals(_,'work'))", null)] + [InlineData("filter", "has(tags,startsWith(_,'w'))", null)] + [InlineData("filter", "has(tags,contains(_,'o'))", null)] + [InlineData("filter", "has(tags,endsWith(_,'k'))", null)] + [InlineData("filter", "has(tags,any(_,'work'))", null)] + [InlineData("filter", "has(tags,not(equals(_,'spam')))", null)] [InlineData("filter[posts]", "equals(caption,null)", "posts")] [InlineData("filter[posts]", "equals(author,null)", "posts")] + [InlineData("filter[posts]", "equals(author,reviewer)", "posts")] [InlineData("filter[posts]", "equals(author.userName,author.displayName)", "posts")] [InlineData("filter[posts.comments]", "lessThan(createdAt,'2000-01-01')", "posts.comments")] [InlineData("filter[posts.comments]", "lessOrEqual(createdAt,'2000-01-01')", "posts.comments")] @@ -216,10 +247,10 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); @@ -232,50 +263,79 @@ public void Throws_When_ResourceType_Scope_Not_Disposed() // Arrange using var serviceProvider = new ServiceContainer(); var resourceFactory = new ResourceFactory(serviceProvider); - var parser = new NotDisposingFilterParser(resourceFactory); + var parser = new NotDisposingContainerScopeFilterParser(resourceFactory); // Act Action action = () => parser.Parse("equals(title,'some')", Request.PrimaryResourceType!); // Assert action.Should().ThrowExactly().WithMessage("There is still a resource type in scope after parsing has completed. " + - "Verify that Dispose() is called on all return values of InScopeOfResourceType()."); + "Verify that Dispose() is called on all return values of InScopeOfContainer()."); } [Fact] - public void Throws_When_No_ResourceType_In_Scope() + public void Throws_When_ScalarCollection_Scope_Not_Disposed() { // Arrange using var serviceProvider = new ServiceContainer(); var resourceFactory = new ResourceFactory(serviceProvider); - var parser = new ResourceTypeAccessingFilterParser(resourceFactory); + var parser = new NotDisposingScalarCollectionScopeFilterParser(resourceFactory); + + // Act + Action action = () => parser.Parse("equals(_,'some')", Request.PrimaryResourceType!); + + // Assert + action.Should().ThrowExactly().WithMessage( + "There is still a scalar collection element type in scope after parsing has completed. " + + "Verify that Dispose() is called on all return values of InScopeOfScalarCollection()."); + } + + [Fact] + public void Throws_When_No_Filter_Scope_Is_Active() + { + // Arrange + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var parser = new ScopeAccessingFilterParser(resourceFactory); // Act Action action = () => parser.Parse("equals(title,'some')", Request.PrimaryResourceType!); // Assert - action.Should().ThrowExactly().WithMessage("No resource type is currently in scope. Call Parse() first."); + action.Should().ThrowExactly().WithMessage("No filter scope is currently active. Call Parse() first."); + } + + private sealed class NotDisposingContainerScopeFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) + { + protected override FilterExpression ParseFilter() + { + // Forgot to dispose the return value. + _ = InScopeOfContainer(((AttributeContainerFilterScope)CurrentScope).Container); + + return base.ParseFilter(); + } } - private sealed class NotDisposingFilterParser(IResourceFactory resourceFactory) + private sealed class NotDisposingScalarCollectionScopeFilterParser(IResourceFactory resourceFactory) : FilterParser(resourceFactory) { protected override FilterExpression ParseFilter() { // Forgot to dispose the return value. - _ = InScopeOfResourceType(ResourceTypeInScope); + _ = InScopeOfScalarCollection(typeof(string)); return base.ParseFilter(); } } - private sealed class ResourceTypeAccessingFilterParser(IResourceFactory resourceFactory) + private sealed class ScopeAccessingFilterParser(IResourceFactory resourceFactory) : FilterParser(resourceFactory) { protected override void Tokenize(string source) { - // There is no resource type in scope yet. - _ = ResourceTypeInScope; + // Fails because there is no scope active yet. + _ = CurrentScope; base.Tokenize(source); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs index dafd39e033..50174f01f6 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -109,9 +109,8 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index eae586ebdb..d8ccffd39e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -31,12 +31,12 @@ public LegacyFilterParseTests() } [Theory] - [InlineData("filter", "Expected field name between brackets in filter parameter name.")] - [InlineData("filter[", "Expected field name between brackets in filter parameter name.")] - [InlineData("filter[]", "Expected field name between brackets in filter parameter name.")] + [InlineData("filter", "Field name between brackets expected in filter parameter name.")] + [InlineData("filter[", "Field name between brackets expected in filter parameter name.")] + [InlineData("filter[]", "Field name between brackets expected in filter parameter name.")] [InlineData("filter[some]", "Field 'some' does not exist on resource type 'blogPosts'.")] [InlineData("filter[author.posts]", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by a to-one relationship or an attribute. " + + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-one relationship or an attribute. " + "To-one relationship or attribute on resource type 'webAccounts' expected.")] [InlineData("filter[some.id]", "Field 'some' does not exist on resource type 'blogPosts'.")] public void Reader_Read_ParameterName_Fails(string parameterName, string errorMessage) @@ -59,15 +59,18 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe } [Theory] - [InlineData("filter[author]", "some", "null expected.")] + [InlineData("filter[author]", "some", + "Filtering on relationship 'author' is only supported using 'isnull:' or 'isnotnull:'. To filter by related attributes, use 'filter[author.id]=123' or use expression syntax.")] + [InlineData("filter[author]", "eq:some", + "Filtering on relationship 'author' is only supported using 'isnull:' or 'isnotnull:'. To filter by related attributes, use 'filter[author.id]=123' or use expression syntax.")] [InlineData("filter", "expr:equals(some,'other')", "Field 'some' does not exist on resource type 'blogPosts'.")] - [InlineData("filter", "expr:equals(author,'Joe')", "null expected.")] + [InlineData("filter", "expr:equals(author,'Joe')", "To-one relationship name or null expected.")] [InlineData("filter", "expr:has(author)", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by a to-many relationship. " + - "Relationship on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute. " + + "Relationship or scalar collection attribute or compound attribute on resource type 'webAccounts' expected.")] [InlineData("filter", "expr:equals(count(author),'1')", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by a to-many relationship. " + - "Relationship on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute. " + + "Relationship or scalar collection attribute or compound attribute on resource type 'webAccounts' expected.")] public void Reader_Read_ParameterValue_Fails(string parameterName, string parameterValue, string errorMessage) { // Act @@ -118,9 +121,8 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index 9841e0fc47..36717885dd 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -54,18 +54,18 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b } [Theory] - [InlineData("^", "Number or relationship name expected.")] - [InlineData("^.", "Number or relationship name expected.")] - [InlineData("1,^", "Number or relationship name expected.")] - [InlineData("^(", "Number or relationship name expected.")] + [InlineData("^", "Number or to-many relationship name expected.")] + [InlineData("^.", "Number or to-many relationship name expected.")] + [InlineData("1,^", "Number or to-many relationship name expected.")] + [InlineData("^(", "Number or to-many relationship name expected.")] [InlineData("^ ", "Unexpected whitespace.")] - [InlineData("-^", "Digits expected.")] + [InlineData("-^", "Number expected.")] [InlineData("^-1", "Page number cannot be negative or zero.")] [InlineData("posts^", ": expected.")] [InlineData("posts:^", "Number expected.")] [InlineData("posts:^abc", "Number expected.")] [InlineData("1^(", ", expected.")] - [InlineData("posts:-^abc", "Digits expected.")] + [InlineData("posts:-^abc", "Number expected.")] [InlineData("posts:^-1", "Page number cannot be negative or zero.")] [InlineData("^unknown", "To-many relationship 'unknown' does not exist on resource type 'blogs'.")] [InlineData("^unknown.other", "Relationship 'unknown' does not exist on resource type 'blogs'.")] @@ -102,18 +102,18 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes } [Theory] - [InlineData("^", "Number or relationship name expected.")] - [InlineData("^.", "Number or relationship name expected.")] - [InlineData("1,^", "Number or relationship name expected.")] - [InlineData("^(", "Number or relationship name expected.")] + [InlineData("^", "Number or to-many relationship name expected.")] + [InlineData("^.", "Number or to-many relationship name expected.")] + [InlineData("1,^", "Number or to-many relationship name expected.")] + [InlineData("^(", "Number or to-many relationship name expected.")] [InlineData("^ ", "Unexpected whitespace.")] - [InlineData("-^", "Digits expected.")] + [InlineData("-^", "Number expected.")] [InlineData("^-1", "Page size cannot be negative.")] [InlineData("posts^", ": expected.")] [InlineData("posts:^", "Number expected.")] [InlineData("posts:^abc", "Number expected.")] [InlineData("1^(", ", expected.")] - [InlineData("posts:-^abc", "Digits expected.")] + [InlineData("posts:-^abc", "Number expected.")] [InlineData("posts:^-1", "Page size cannot be negative.")] [InlineData("^unknown", "To-many relationship 'unknown' does not exist on resource type 'blogs'.")] [InlineData("^unknown.other", "Relationship 'unknown' does not exist on resource type 'blogs'.")] @@ -175,9 +175,8 @@ public void Reader_Read_Pagination_Succeeds(string? pageNumber, string? pageSize _reader.Read("page[size]", pageSize); } - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); string[] scopeTreesExpectedArray = scopeTreesExpected.Split("|"); ResourceFieldChainExpression?[] scopeTrees = constraints.Select(expressionInScope => expressionInScope.Scope).ToArray(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/QueryParseChoiceFormatterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/QueryParseChoiceFormatterTests.cs new file mode 100644 index 0000000000..eef424643b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/QueryParseChoiceFormatterTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; + +public sealed class QueryParseChoiceFormatterTests +{ + [Theory] + [InlineData(QueryParseChoices.Minus, "- expected.")] + [InlineData(QueryParseChoices.OpenParenthesis, "( expected.")] + [InlineData(QueryParseChoices.FilterFunction, "Filter function expected.")] + [InlineData(QueryParseChoices.CountFunction, "count function expected.")] + [InlineData(QueryParseChoices.Function, "Function expected.")] + [InlineData(QueryParseChoices.Number, "Number expected.")] + [InlineData(QueryParseChoices.ParameterName, "Parameter name expected.")] + [InlineData(QueryParseChoices.ResourceType, "Resource type expected.")] + [InlineData(QueryParseChoices.FieldName, "Field name expected.")] + [InlineData(QueryParseChoices.ToOneRelationshipName, "To-one relationship name expected.")] + [InlineData(QueryParseChoices.ToManyRelationshipName, "To-many relationship name expected.")] + [InlineData(QueryParseChoices.RelationshipName, "Relationship name expected.")] + [InlineData(QueryParseChoices.QuotedText, "Value between quotes expected.")] + [InlineData(QueryParseChoices.Underscore, "_ expected.")] + [InlineData(QueryParseChoices.Null, "null expected.")] + [InlineData(QueryParseChoices.Comma, ", expected.")] + [InlineData(QueryParseChoices.EndOfExpression, "End of expression expected.")] + public void Formats_each_single_choice(QueryParseChoices choice, string messageExpected) + { + // Act + string messageActual = QueryParseChoiceFormatter.Format(choice); + + // Assert + messageActual.Should().Be(messageExpected); + } + + [Theory] + [InlineData(QueryParseChoices.Function | QueryParseChoices.FieldName, "Function or field name expected.")] + [InlineData(QueryParseChoices.Function | QueryParseChoices.FieldName | QueryParseChoices.QuotedText, + "Function, field name or value between quotes expected.")] + [InlineData(QueryParseChoices.Function | QueryParseChoices.FieldName | QueryParseChoices.QuotedText | QueryParseChoices.Null, + "Function, field name, value between quotes or null expected.")] + public void Formats_combinations_of_varying_lengths(QueryParseChoices choices, string messageExpected) + { + // Act + string messageActual = QueryParseChoiceFormatter.Format(choices); + + // Assert + messageActual.Should().Be(messageExpected); + } + + [Theory] + [InlineData(QueryParseChoices.FieldName, "Field name expected.")] + [InlineData(QueryParseChoices.Minus | QueryParseChoices.FieldName, "- or field name expected.")] + [InlineData(QueryParseChoices.Null, "null expected.")] + [InlineData(QueryParseChoices.Null | QueryParseChoices.Comma, "null or , expected.")] + [InlineData(QueryParseChoices.CountFunction | QueryParseChoices.FieldName, "count function or field name expected.")] + [InlineData(QueryParseChoices.FieldName | QueryParseChoices.Null, "Field name or null expected.")] + [InlineData(QueryParseChoices.Minus | QueryParseChoices.CountFunction | QueryParseChoices.FieldName, "-, count function or field name expected.")] + public void Handles_casing_and_symbols_at_first_and_subsequent_positions(QueryParseChoices choices, string messageExpected) + { + // Act + string messageActual = QueryParseChoiceFormatter.Format(choices); + + // Assert + messageActual.Should().Be(messageExpected); + } + + [Theory] + [InlineData(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.ToManyRelationshipName, "Relationship name expected.")] + [InlineData(QueryParseChoices.Number | QueryParseChoices.ToOneRelationshipName | QueryParseChoices.ToManyRelationshipName, + "Number or relationship name expected.")] + public void Collapses_relationship_choices(QueryParseChoices choices, string messageExpected) + { + // Act + string messageActual = QueryParseChoiceFormatter.Format(choices); + + // Assert + messageActual.Should().Be(messageExpected); + } + + [Fact] + public void Formats_only_custom_choices() + { + // Act + string message = QueryParseChoiceFormatter.Format(QueryParseChoices.None, "first", "second", "third"); + + // Assert + message.Should().Be("first, second or third expected."); + } + + [Fact] + public void Formats_built_in_combined_with_custom_choices() + { + // Act + string message = QueryParseChoiceFormatter.Format(QueryParseChoices.Function | QueryParseChoices.FieldName, "one", "two"); + + // Assert + message.Should().Be("Function, field name, one or two expected."); + } + + [Fact] + public void Ignores_empty_or_whitespace_custom_choices() + { + // Act + string message = QueryParseChoiceFormatter.Format(QueryParseChoices.FieldName, string.Empty, " ", "some"); + + // Assert + message.Should().Be("Field name or some expected."); + } + + [Fact] + public void Formats_empty_choices_to_empty_string() + { + // Act + string messageNone = QueryParseChoiceFormatter.Format(QueryParseChoices.None); + + // Assert + messageNone.Should().BeEmpty(); + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index 0fb39c44d6..e5f53000ca 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -94,11 +94,11 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [Theory] [InlineData("sort", "^", "-, count function or field name expected.")] [InlineData("sort", "^ ", "Unexpected whitespace.")] - [InlineData("sort", "-^", "Count function or field name expected.")] + [InlineData("sort", "-^", "count function or field name expected.")] [InlineData("sort", "^abc", "Field 'abc' does not exist on resource type 'blogs'.")] [InlineData("sort[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.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute. " + + "To-one relationship or non-collection attribute on resource type 'webAccounts' expected.")] [InlineData("sort[posts]", "author.^livingAddress", "Field 'livingAddress' does not exist on resource type 'webAccounts'.")] [InlineData("sort", "-count^", "( expected.")] [InlineData("sort", "count^", "( expected.")] @@ -107,14 +107,14 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("sort", "count(^-abc)", "Field name expected.")] [InlineData("sort", "count(^abc)", "Field 'abc' does not exist on resource type 'blogs'.")] [InlineData("sort", "count(^id)", - "Field chain on resource type 'blogs' failed to match the pattern: zero or more to-one relationships, followed by a to-many relationship. " + - "Relationship on resource type 'blogs' expected.")] + "Field chain on resource type 'blogs' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute. " + + "Relationship or scalar collection attribute or compound attribute on resource type 'blogs' expected.")] [InlineData("sort[posts]", "count(author^)", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by a to-many relationship. " + - "Relationship on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute. " + + "Relationship or scalar collection attribute or compound attribute on resource type 'webAccounts' expected.")] [InlineData("sort[posts]", "caption,^", "-, count function or field name expected.")] [InlineData("sort[posts]", "caption^:", ", expected.")] - [InlineData("sort[posts]", "caption,-^", "Count function or field name expected.")] + [InlineData("sort[posts]", "caption,-^", "count function or field name expected.")] [InlineData("sort[posts.contributors]", "^some", "Field 'some' does not exist on resource type 'humans' or any of its derived types.")] [InlineData("sort[posts.contributors]", "wife.father.^some", "Field 'some' does not exist on resource type 'men'.")] [InlineData("sort[posts.contributors]", "count(^some)", "Field 'some' does not exist on resource type 'humans' or any of its derived types.")] @@ -165,9 +165,8 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert + RelationshipChainExpressionInScope[] constraints = _reader.GetConstraints().Cast().ToArray(); ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index 19991bacae..d37505e85c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Serialization.Objects; @@ -90,7 +89,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("fields[blogPosts]", "id^(", ", expected.")] [InlineData("fields[blogPosts]", "id,^", "Field name expected.")] [InlineData("fields[blogPosts]", "author.^id,", - "Field chain on resource type 'blogPosts' failed to match the pattern: a field. End of field chain expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more compound attributes, followed by a field. " + + "End of field chain expected.")] public void Reader_Read_ParameterValue_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -114,22 +114,18 @@ public void Reader_Read_ParameterValue_Fails(string parameterName, string parame } [Theory] - [InlineData("fields[blogPosts]", "caption,url,author", "blogPosts(author,caption,url)")] - [InlineData("fields[blogPosts]", "author,comments,labels", "blogPosts(author,comments,labels)")] - [InlineData("fields[blogs]", "id", "blogs(id)")] - [InlineData("fields[blogs]", "", "blogs(id)")] - public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected) + [InlineData("fields[blogPosts]", "caption,url,author", "blogPosts", "author,caption,url")] + [InlineData("fields[blogPosts]", "author,comments,labels", "blogPosts", "author,comments,labels")] + [InlineData("fields[blogs]", "id", "blogs", "id")] + [InlineData("fields[blogs]", "", "blogs", "")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string resourceTypeExpected, string valueExpected) { // Act _reader.Read(parameterName, parameterValue); - IReadOnlyCollection constraints = _reader.GetConstraints(); - // Assert - ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); - scope.Should().BeNull(); - - QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); - value.ToString().Should().Be(valueExpected); + ResourceTypeExpressionInScope constraint = _reader.GetConstraints().Cast().ToArray().Single(); + constraint.ResourceType.PublicName.Should().Be(resourceTypeExpected); + constraint.Expression.ToString().Should().Be(valueExpected); } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs new file mode 100644 index 0000000000..667ebf2a99 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -0,0 +1,377 @@ +using System.ComponentModel.Design; +using System.Linq.Expressions; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryableBuilding; + +public sealed class ClauseBuilderTests : IDisposable +{ + private readonly ResourceType _resourceType; + private readonly IReadOnlyModel _entityModel; + private readonly ServiceContainer _serviceContainer; + private readonly IResourceFactory _resourceFactory; + private readonly FilterParser _filterParser; + private readonly SortParser _sortParser; + + public ClauseBuilderTests() + { + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + + _resourceType = resourceGraph.GetResourceType(); + + var modelBuilder = new ModelBuilder(); + modelBuilder.Entity(); + _entityModel = modelBuilder.Model.FinalizeModel(); + + _serviceContainer = new ServiceContainer(); + _resourceFactory = new ResourceFactory(_serviceContainer); + _filterParser = new FilterParser(_resourceFactory); + _sortParser = new SortParser(); + } + + [Fact] + public void Builds_where_clause_for_nested_compound_attribute() + { + // Arrange + FilterExpression filter = _filterParser.Parse("equals(details.title,'abc')", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => (compoundModel.Details.Title == value("); + } + + [Fact] + public void Builds_where_clause_for_null_comparison_on_compound_attribute() + { + // Arrange + FilterExpression filter = _filterParser.Parse("equals(details,null)", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => (compoundModel.Details == Convert(null, CompoundRoot)))"); + } + + [Fact] + public void Builds_where_clause_for_compound_collection_has() + { + // Arrange + FilterExpression filter = _filterParser.Parse("has(details.items,equals(label,'work'))", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => compoundModel.Details.Items.Any(compoundItem => (compoundItem.Label == value("); + } + + [Fact] + public void Builds_where_clause_for_primitive_collection_has_with_element_symbol() + { + // Arrange + FilterExpression filter = _filterParser.Parse("has(tags,equals(_,'urgent'))", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => compoundModel.Tags.Any(string => (string == value("); + } + + [Fact] + public void Builds_where_clause_for_primitive_collection_has_with_startsWith() + { + // Arrange + FilterExpression filter = _filterParser.Parse("has(tags,startsWith(_,'urg'))", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => compoundModel.Tags.Any(string => string.StartsWith(value("); + } + + [Fact] + public void Builds_where_clause_for_collection_count() + { + // Arrange + FilterExpression filter = _filterParser.Parse("greaterThan(count(details.items),'1')", _resourceType); + + var whereClauseBuilder = new WhereClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = whereClauseBuilder.ApplyWhere(filter, context); + + // Assert + result.ToString().Should().Contain(".Where(compoundModel => (compoundModel.Details.Items.Count > value("); + } + + [Fact] + public void Builds_order_by_clause_for_nested_scalar() + { + // Arrange + SortExpression sort = _sortParser.Parse("-details.nested.description,details.title", _resourceType); + + var orderClauseBuilder = new OrderClauseBuilder(); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = orderClauseBuilder.ApplyOrderBy(sort, context); + + // Assert + result.ToString().Should().Contain( + ".OrderByDescending(compoundModel => compoundModel.Details.Nested.Description).ThenBy(compoundModel => compoundModel.Details.Title)"); + } + + [Fact] + public void Builds_select_clause_for_compound_attribute() + { + // Arrange + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(_resourceType); + AttrAttribute detailsAttr = _resourceType.GetAttributeByPropertyName(nameof(CompoundModel.Details)); + selectors.IncludeAttribute(detailsAttr); + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(detailsAttr); + AttrAttribute titleAttr = detailsAttr.CompoundType!.GetAttributeByPropertyName(nameof(CompoundRoot.Title)); + childSelectors.IncludeAttribute(titleAttr); + + var selectClauseBuilder = new SelectClauseBuilder(_resourceFactory); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = selectClauseBuilder.ApplySelect(selection, context); + + // Assert + result.ToString().Should().Contain("new CompoundRoot() {Title = compoundModel.Details.Title}"); + } + + [Fact] + public void Builds_select_clause_for_nested_compound_attribute() + { + // Arrange + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(_resourceType); + AttrAttribute detailsAttr = _resourceType.GetAttributeByPropertyName(nameof(CompoundModel.Details)); + selectors.IncludeAttribute(detailsAttr); + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(detailsAttr); + AttrAttribute nestedAttr = detailsAttr.CompoundType!.GetAttributeByPropertyName(nameof(CompoundRoot.Nested)); + childSelectors.IncludeAttribute(nestedAttr); + FieldSelectors grandchildSelectors = childSelectors.GetOrCreateChildSelectors(nestedAttr); + AttrAttribute descAttr = nestedAttr.CompoundType!.GetAttributeByPropertyName(nameof(CompoundNested.Description)); + grandchildSelectors.IncludeAttribute(descAttr); + + var selectClauseBuilder = new SelectClauseBuilder(_resourceFactory); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = selectClauseBuilder.ApplySelect(selection, context); + + // Assert + result.ToString().Should().Contain("new CompoundNested() {Description = compoundModel.Details.Nested.Description}"); + } + + [Fact] + public void Builds_select_clause_for_compound_collection_attribute_without_crashing() + { + // Arrange + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(_resourceType); + AttrAttribute itemsAttr = _resourceType.GetAttributeByPropertyName(nameof(CompoundModel.Items)); + selectors.IncludeAttribute(itemsAttr); + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(itemsAttr); + AttrAttribute labelAttr = itemsAttr.CompoundType!.GetAttributeByPropertyName(nameof(CompoundItem.Label)); + childSelectors.IncludeAttribute(labelAttr); + + var selectClauseBuilder = new SelectClauseBuilder(_resourceFactory); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = selectClauseBuilder.ApplySelect(selection, context); + + // Assert + result.ToString().Should().Contain("Items = compoundModel.Items"); + } + + [Fact] + public void Builds_select_clause_for_compound_attribute_with_readonly_property() + { + // Arrange + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(_resourceType); + AttrAttribute detailsAttr = _resourceType.GetAttributeByPropertyName(nameof(CompoundModel.Details)); + selectors.IncludeAttribute(detailsAttr); + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(detailsAttr); + AttrAttribute calcAttr = detailsAttr.CompoundType!.GetAttributeByPropertyName(nameof(CompoundRoot.Calculated)); + childSelectors.IncludeAttribute(calcAttr); + + var selectClauseBuilder = new SelectClauseBuilder(_resourceFactory); + var lambdaScopeFactory = new LambdaScopeFactory(); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(typeof(CompoundModel)); + + IQueryable source = Array.Empty().AsQueryable(); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); + + // Act + Expression result = selectClauseBuilder.ApplySelect(selection, context); + + // Assert + result.ToString().Should().Contain("Title = compoundModel.Details.Title"); + result.ToString().Should().NotContain("Name = compoundModel.Name"); + } + + public void Dispose() + { + _serviceContainer.Dispose(); + } + + private sealed class DummyQueryableBuilder : IQueryableBuilder + { + public Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) + { + throw new NotImplementedException(); + } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class CompoundModel : Identifiable + { + [Attr] + public string? Name { get; set; } + + [Attr(IsCompound = true)] + public CompoundRoot? Details { get; set; } + + [Attr] + public List Tags { get; set; } = []; + + [Attr(IsCompound = true)] + public List Items { get; set; } = []; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundRoot + { + [Attr] + public string? Title { get; set; } + + [Attr] + public string? Calculated => Title?.ToUpperInvariant(); + + [Attr(IsCompound = true)] + public CompoundNested? Nested { get; set; } + + [Attr] + public List Numbers { get; set; } = []; + + [Attr(IsCompound = true)] + public List Items { get; set; } = []; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundNested + { + [Attr] + public string? Description { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundItem + { + [Attr] + public string? Label { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs new file mode 100644 index 0000000000..1d130034da --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class CompoundTypeTests +{ + [Fact] + public void Can_lookup_attributes_in_compound_type() + { + // Arrange + PropertyInfo streetProperty = typeof(TestAddress).GetProperty(nameof(TestAddress.Street))!; + + var streetAttribute = new AttrAttribute + { + PublicName = "street", + Property = streetProperty + }; + + var compoundType = new CompoundType(typeof(TestAddress), [streetAttribute]); + + // Act & Assert + compoundType.ClrType.Should().Be(); + compoundType.Attributes.Should().ContainSingle().Which.Should().Be(streetAttribute); + compoundType.ToString().Should().Be(nameof(TestAddress)); + + compoundType.FindAttributeByPublicName("street").Should().Be(streetAttribute); + compoundType.FindAttributeByPublicName("unknown").Should().BeNull(); + compoundType.GetAttributeByPublicName("street").Should().Be(streetAttribute); + Action getUnknownPublic = () => compoundType.GetAttributeByPublicName("unknown"); + getUnknownPublic.Should().ThrowExactly().WithMessage("Attribute 'unknown' does not exist on compound type 'TestAddress'."); + + compoundType.FindAttributeByPropertyName(nameof(TestAddress.Street)).Should().Be(streetAttribute); + compoundType.FindAttributeByPropertyName("Unknown").Should().BeNull(); + compoundType.GetAttributeByPropertyName(nameof(TestAddress.Street)).Should().Be(streetAttribute); + Action getUnknownProp = () => compoundType.GetAttributeByPropertyName("Unknown"); + + getUnknownProp.Should().ThrowExactly() + .WithMessage("Attribute for property 'Unknown' does not exist on compound type 'TestAddress'."); + } + + [Fact] + public void Can_lookup_compound_type_in_resource_graph() + { + // Arrange + var compoundType = new CompoundType(typeof(TestAddress), []); + + var resourceGraph = new JsonApiDotNetCore.Configuration.ResourceGraph(new HashSet().AsReadOnly(), new HashSet + { + compoundType + }.AsReadOnly()); + + // Act & Assert + resourceGraph.GetCompoundTypes().Should().ContainSingle().Which.Should().Be(compoundType); + resourceGraph.FindCompoundType().Should().Be(compoundType); + resourceGraph.FindCompoundType().Should().BeNull(); + + resourceGraph.GetCompoundType().Should().Be(compoundType); + Action getUnknown = () => resourceGraph.GetCompoundType(); + getUnknown.Should().ThrowExactly().WithMessage("Compound type 'System.String' does not exist in the resource graph."); + } + + private sealed class TestAddress + { + public string? Street { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index b7a028bd6e..35f67ac648 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -1,8 +1,11 @@ using System.Reflection; using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; @@ -230,6 +233,63 @@ public void Cannot_set_value_to_incompatible_value() action.Should().ThrowExactly().WithMessage("Object of type 'System.DateTime' cannot be converted to type 'System.Boolean'."); } + [Fact] + public void Evaluates_IsCollection_correctly() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + ResourceType resourceType = resourceGraph.GetResourceType(); + AttrAttribute scalarAttr = resourceType.GetAttributeByPropertyName(nameof(TestResourceWithCollections.ScalarString)); + AttrAttribute listAttr = resourceType.GetAttributeByPropertyName(nameof(TestResourceWithCollections.StringList)); + AttrAttribute setAttr = resourceType.GetAttributeByPropertyName(nameof(TestResourceWithCollections.IntSet)); + + // Assert + scalarAttr.IsCollection.Should().BeFalse(); + listAttr.IsCollection.Should().BeTrue(); + setAttr.IsCollection.Should().BeTrue(); + + var customAttr = new AttrAttribute + { + IsCollection = true + }; + + customAttr.IsCollection.Should().BeTrue(); + } + + [Fact] + public void Can_set_and_get_IsCompound_and_CompoundType() + { + // Arrange + var compoundType = new CompoundType(typeof(TestResource), []); + + var attribute = new AttrAttribute + { + IsCompound = true, + CompoundType = compoundType + }; + + // Assert + attribute.IsCompound.Should().BeTrue(); + attribute.CompoundType.Should().Be(compoundType); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class TestResourceWithCollections : Identifiable + { + [Attr] + public string? ScalarString { get; set; } + + [Attr] + public List? StringList { get; set; } + + [Attr] + public ISet? IntSet { get; set; } + } + private sealed class TestResource : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index a12af46e1b..c82bf219d1 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -2,7 +2,9 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; @@ -444,6 +446,275 @@ public void Can_override_capabilities_on_Id_property() idAttribute.Capabilities.Should().Be(AttrCapabilities.AllowFilter); } + [Fact] + public void Discovers_compound_types_recursively() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + // Assert + ResourceType resourceType = resourceGraph.GetResourceType(); + AttrAttribute contentAttr = resourceType.GetAttributeByPropertyName(nameof(ResourceWithCompoundAttributes.Content)); + contentAttr.IsCompound.Should().BeTrue(); + contentAttr.CompoundType.Should().NotBeNull(); + contentAttr.ContainingType.Should().Be(resourceType); + + CompoundType rootCompoundType = contentAttr.CompoundType!; + rootCompoundType.ClrType.Should().Be(); + rootCompoundType.Attributes.Should().HaveCount(2); + + AttrAttribute nameAttr = rootCompoundType.GetAttributeByPropertyName(nameof(TestRootCompound.Name)); + nameAttr.IsCompound.Should().BeTrue(); + nameAttr.CompoundType.Should().NotBeNull(); + nameAttr.ContainingType.Should().Be(rootCompoundType); + + CompoundType nameCompoundType = nameAttr.CompoundType!; + nameCompoundType.ClrType.Should().Be(); + AttrAttribute firstAttr = nameCompoundType.GetAttributeByPropertyName(nameof(TestChildCompound.FirstName)); + firstAttr.IsCompound.Should().BeFalse(); + firstAttr.CompoundType.Should().BeNull(); + firstAttr.ContainingType.Should().Be(nameCompoundType); + + AttrAttribute phonesAttr = rootCompoundType.GetAttributeByPropertyName(nameof(TestRootCompound.PhoneNumbers)); + phonesAttr.IsCompound.Should().BeTrue(); + phonesAttr.IsCollection.Should().BeTrue(); + phonesAttr.CompoundType.Should().NotBeNull(); + phonesAttr.CompoundType!.ClrType.Should().Be(); + + resourceGraph.GetCompoundTypes().Should().HaveCount(3); + resourceGraph.GetCompoundType().Should().Be(rootCompoundType); + resourceGraph.GetCompoundType().Should().Be(nameCompoundType); + resourceGraph.GetCompoundType().Should().Be(phonesAttr.CompoundType); + } + + [Fact] + public void Generic_Find_and_Get_overloads_on_ResourceGraph_succeed_and_fail_as_expected() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + // Act & Assert + resourceGraph.FindResourceType().Should().NotBeNull(); + resourceGraph.FindResourceType()!.ClrType.Should().Be(); + resourceGraph.FindResourceType().Should().BeNull(); + + resourceGraph.GetCompoundType().ClrType.Should().Be(); + resourceGraph.FindCompoundType().Should().NotBeNull(); + resourceGraph.FindCompoundType()!.ClrType.Should().Be(); + resourceGraph.FindCompoundType().Should().BeNull(); + + Action action = () => resourceGraph.GetCompoundType(); + action.Should().ThrowExactly(); + } + + [Fact] + public void GetFieldChain_and_GetFieldChains_resolve_nested_compound_chains_and_fail_on_invalid_expressions() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + // Act + ResourceFieldChainExpression singleChain = resourceGraph.GetFieldChain(x => x.Content!.Name!.FirstName); + + IReadOnlyCollection multiChains = resourceGraph.GetFieldChains(x => new + { + x.Content!.Name!.FirstName, + x.Content.PhoneNumbers + }); + + // Assert + singleChain.ToString().Should().Be("content.name.firstName"); + singleChain.Fields.Should().HaveCount(3); + + multiChains.Should().HaveCount(2); + multiChains.Select(c => c.ToString()).Should().BeEquivalentTo("content.name.firstName", "content.phoneNumbers"); + + Action multipleInSingleAction = () => resourceGraph.GetFieldChain(x => new + { + x.Content!.Name!.FirstName, + x.Content.PhoneNumbers + }); + + multipleInSingleAction.Should().ThrowExactly(); + + Action invalidPropertyAction = () => resourceGraph.GetFieldChain(x => x.Id.ToString()); + invalidPropertyAction.Should().ThrowExactly(); + } + + [Fact] + public void Automatically_strips_AllowSort_from_compound_attributes() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + // Assert + ResourceType resourceType = resourceGraph.GetResourceType(); + AttrAttribute attr = resourceType.GetAttributeByPropertyName(nameof(ResourceWithSortableCompound.Compound)); + attr.Capabilities.HasFlag(AttrCapabilities.AllowSort).Should().BeFalse(); + } + + [Fact] + public void Cannot_use_identifiable_type_as_compound_attribute() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Property '{nameof(ResourceWithIdentifiableAsCompound)}.{nameof(ResourceWithIdentifiableAsCompound.Child)}' implements IIdentifiable and must be exposed as a JSON:API relationship using [HasOne] or [HasMany], not as a compound attribute."); + } + + [Fact] + public void Cannot_use_resource_type_as_compound_type() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Property '{nameof(ResourceWithRegisteredResourceAsCompound)}.{nameof(ResourceWithRegisteredResourceAsCompound.Child)}' implements IIdentifiable and must be exposed as a JSON:API relationship using [HasOne] or [HasMany], not as a compound attribute."); + } + + [Fact] + public void Cannot_annotate_compound_type_with_ResourceAttribute() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Type '{typeof(CompoundWithResourceAnnotation)}' is a compound type and cannot be annotated with [Resource]."); + } + + [Fact] + public void Cannot_use_HasOne_inside_compound_type() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Property '{nameof(CompoundWithHasOne)}.{nameof(CompoundWithHasOne.Related)}' in compound type '{nameof(CompoundWithHasOne)}' cannot be annotated with [HasOneAttribute]. Compound types cannot contain relationships."); + } + + [Fact] + public void Cannot_use_HasMany_inside_compound_type() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Property '{nameof(CompoundWithHasMany)}.{nameof(CompoundWithHasMany.Related)}' in compound type '{nameof(CompoundWithHasMany)}' cannot be annotated with [HasManyAttribute]. Compound types cannot contain relationships."); + } + + [Fact] + public void Cannot_use_EagerLoad_inside_compound_type() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Property '{nameof(CompoundWithEagerLoad)}.{nameof(CompoundWithEagerLoad.SubObject)}' in compound type '{nameof(CompoundWithEagerLoad)}' cannot be annotated with [EagerLoad]."); + } + + [Fact] + public void Detects_direct_circular_reference_in_compound_types() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Circular reference detected: compound type '{typeof(DirectCycleCompound)}' references itself directly or indirectly."); + } + + [Fact] + public void Detects_indirect_circular_reference_in_compound_types() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Circular reference detected: compound type '{typeof(IndirectCycleCompoundA)}' references itself directly or indirectly."); + } + + [Fact] + public void Supports_empty_base_class_in_compound_type_hierarchy() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + // Assert + resourceGraph.FindCompoundType().Should().NotBeNull(); + CompoundType compoundType = resourceGraph.GetCompoundType(); + compoundType.Attributes.Should().ContainSingle(attr => attr.Property.Name == nameof(DerivedCompound.Value)); + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class ResourceWithHasOneRelationship : Identifiable { @@ -562,6 +833,169 @@ public sealed class DerivedAlternateToOneRelationshipName : AbstractBaseResource public override AbstractBaseResource? BaseToOne { get; set; } } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithSortableCompound : Identifiable + { + [Attr(Capabilities = AttrCapabilities.All, IsCompound = true)] + public TestChildCompound? Compound { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class SimpleResource : Identifiable; + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithIdentifiableAsCompound : Identifiable + { + [Attr(IsCompound = true)] + public SimpleResource? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithRegisteredResourceAsCompound : Identifiable + { + [Attr(IsCompound = true)] + public SimpleResource? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] + public sealed class CompoundWithResourceAnnotation + { + [Attr] + public string? Text { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithAnnotatedCompound : Identifiable + { + [Attr(IsCompound = true)] + public CompoundWithResourceAnnotation? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundWithHasOne + { + [HasOne] + public SimpleResource? Related { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithHasOneInCompound : Identifiable + { + [Attr(IsCompound = true)] + public CompoundWithHasOne? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundWithHasMany + { + [HasMany] + public ISet Related { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithHasManyInCompound : Identifiable + { + [Attr(IsCompound = true)] + public CompoundWithHasMany? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class CompoundWithEagerLoad + { + [EagerLoad] + public TestChildCompound? SubObject { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithEagerLoadInCompound : Identifiable + { + [Attr(IsCompound = true)] + public CompoundWithEagerLoad? Child { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DirectCycleCompound + { + [Attr(IsCompound = true)] + public DirectCycleCompound? Next { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithDirectCycle : Identifiable + { + [Attr(IsCompound = true)] + public DirectCycleCompound? Cycle { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class IndirectCycleCompoundA + { + [Attr(IsCompound = true)] + public IndirectCycleCompoundB? Next { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class IndirectCycleCompoundB + { + [Attr(IsCompound = true)] + public IndirectCycleCompoundA? Next { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithIndirectCycle : Identifiable + { + [Attr(IsCompound = true)] + public IndirectCycleCompoundA? Cycle { get; set; } + } + + public abstract class EmptyCompoundBase; + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DerivedCompound : EmptyCompoundBase + { + [Attr] + public string? Value { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithDerivedCompound : Identifiable + { + [Attr(IsCompound = true)] + public DerivedCompound? Compound { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithCompoundAttributes : Identifiable + { + [Attr(IsCompound = true)] + public TestRootCompound? Content { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TestRootCompound + { + [Attr(IsCompound = true)] + public TestChildCompound? Name { get; set; } + + [Attr(IsCompound = true)] + public List PhoneNumbers { get; set; } = []; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TestChildCompound + { + [Attr] + public string? FirstName { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TestPhoneCompound + { + [Attr] + public string? Number { get; set; } + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DerivedAlternateToManyRelationshipName : AbstractBaseResource { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs index 85329874ea..a254dbb472 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs @@ -31,12 +31,12 @@ public void Fails_when_derived_type_is_missing_in_resource_graph() var linkBuilder = new FakeLinkBuilder(); var metaBuilder = new FakeMetaBuilder(); var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); - var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty(), resourceDefinitionAccessor); + var sparseFieldSetEvaluator = new SparseFieldSetEvaluator(Array.Empty(), resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var responseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, - sparseFieldSetCache, requestQueryStringAccessor); + sparseFieldSetEvaluator, requestQueryStringAccessor); var banana = new Banana(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs index 3a60bd1279..7fa0b0716c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -598,14 +598,14 @@ private ResponseModelAdapter CreateAdapter(IJsonApiOptions options, string? prim var linkBuilder = new FakeLinkBuilder(); var metaBuilder = new FakeMetaBuilder(); var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); - var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty(), resourceDefinitionAccessor); + var sparseFieldSetEvaluator = new SparseFieldSetEvaluator(Array.Empty(), resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); var includeParser = new IncludeParser(options); IncludeExpression include = includeParser.Parse(includeChains, request.PrimaryResourceType); evaluatedIncludeCache.Set(include); - return new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetCache, + return new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetEvaluator, requestQueryStringAccessor); } } diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index 524270f7e2..74013c7cb2 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index 9ff82322ba..41343f2140 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index 61946fbc1a..e197039888 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 diff --git a/test/TestBuildingBlocks/CapturingLoggerProvider.cs b/test/TestBuildingBlocks/CapturingLoggerProvider.cs index d0894696a1..dcdd2fdb0b 100644 --- a/test/TestBuildingBlocks/CapturingLoggerProvider.cs +++ b/test/TestBuildingBlocks/CapturingLoggerProvider.cs @@ -1,12 +1,5 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; -using LockPrimitive = -#if NET9_0_OR_GREATER - System.Threading.Lock -#else - object -#endif - ; namespace TestBuildingBlocks; @@ -16,7 +9,7 @@ public sealed class CapturingLoggerProvider : ILoggerProvider private static readonly Func DefaultFilter = (_, _) => true; private readonly Func _filter; - private readonly LockPrimitive _lockObject = new(); + private readonly Lock _lockObject = new(); private readonly List _messages = []; public CapturingLoggerProvider() diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 6edcde897c..c414d8c9c9 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0