From fa5cae672848a2208f4fc4efad3e603863b67c72 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:23 +0200 Subject: [PATCH 01/63] Drop the net9.0 and net8.0 target frameworks Co-authored-by: Cursor --- .github/workflows/build.yml | 6 - .github/workflows/codeql.yml | 2 - JsonApiDotNetCore.slnx.DotSettings | 1 - package-versions.props | 21 +-- .../DapperExample/DapperExample.csproj | 5 +- src/Examples/DapperExample/Program.cs | 4 - .../DatabasePerTenantExample.csproj | 2 +- .../GettingStarted/GettingStarted.csproj | 2 +- .../JsonApiDotNetCoreExample.csproj | 2 +- .../MultiDbContextExample.csproj | 2 +- .../NoEntityFrameworkExample.csproj | 2 +- .../ReportsExample/ReportsExample.csproj | 2 +- .../JsonApiDotNetCore.Annotations.csproj | 2 +- .../ReadOnlySet.cs | 171 ------------------ .../JsonApiDotNetCore.csproj | 8 +- test/AnnotationTests/AnnotationTests.csproj | 2 +- test/DapperTests/DapperTests.csproj | 2 +- test/DiscoveryTests/DiscoveryTests.csproj | 2 +- .../JsonApiDotNetCoreTests.csproj | 3 +- .../MultiDbContextTests.csproj | 2 +- .../NoEntityFrameworkTests.csproj | 2 +- .../SourceGeneratorTests.csproj | 2 +- .../CapturingLoggerProvider.cs | 9 +- .../TestBuildingBlocks.csproj | 2 +- 24 files changed, 20 insertions(+), 238 deletions(-) delete mode 100644 src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs 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/JsonApiDotNetCore.slnx.DotSettings b/JsonApiDotNetCore.slnx.DotSettings index 1016e6cda0..1509f84a1f 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 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/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/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/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/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/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/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/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 788f4eabff..edbb9aae48 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,6 +1,6 @@ - net10.0;net9.0;net8.0 + net10.0 @@ -20,6 +20,5 @@ - 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 From ec16b95f134f323f6dc55afed7f2d69600776ad1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:43:54 +0200 Subject: [PATCH 02/63] Introduce IAttributeContainer interface and implement on ResourceType Co-authored-by: Cursor --- .../Configuration/IAttributeContainer.cs | 31 +++++++++++++++++++ .../Configuration/ResourceType.cs | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs new file mode 100644 index 0000000000..dba17965da --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs @@ -0,0 +1,31 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Represents a schema element (such as a resource type or a compound type) that contains JSON:API attributes. +/// +[PublicAPI] +public interface IAttributeContainer +{ + /// + /// The CLR type of the container. + /// + Type ClrType { get; } + + /// + /// The attributes contained in this schema element. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Attempts to find an attribute by its publicly exposed name. Returns null when not found. + /// + AttrAttribute? FindAttributeByPublicName(string publicName); + + /// + /// 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..9258ed160f 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(); From 0993e12629cbd6f000813bb262bfb7b4258ff4d8 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:45:05 +0200 Subject: [PATCH 03/63] Introduce CompoundType descriptor and register in IResourceGraph Co-authored-by: Cursor --- .../Configuration/CompoundType.cs | 105 ++++++++++++++++++ .../Configuration/IResourceGraph.cs | 15 +++ .../Configuration/ResourceGraph.cs | 43 +++++++ .../ResourceGraph/CompoundTypeTests.cs | 78 +++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs new file mode 100644 index 0000000000..7203578260 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -0,0 +1,105 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of an embedded compound value object 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 = null) + { + ArgumentNullException.ThrowIfNull(clrType); + + ClrType = clrType; + Attributes = attributes ?? Array.Empty(); + + foreach (AttrAttribute attribute in Attributes) + { + _attributesByPublicName.Add(attribute.PublicName, attribute); + _attributesByPropertyName.Add(attribute.Property.Name, attribute); + } + } + + /// + /// Gets the attribute for the specified publicly exposed name. Throws an when not found. + /// + 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); + } + + /// + /// Gets the attribute for the specified property name. Throws an when not found. + /// + 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/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index a2c4f3283c..e4c20dc348 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -43,6 +43,21 @@ ResourceType GetResourceType() /// ResourceType? FindResourceType(Type resourceClrType); + /// + /// 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); + + /// + /// Attempts to get metadata for the compound type of the specified CLR type. Returns null when not found. + /// + CompoundType? FindCompoundType(Type clrType); + /// /// Gets the fields (attributes and relationships) for that are targeted by the selector. /// diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index a4ffd082d3..0c0429eef3 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -13,21 +13,37 @@ 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) + : this(resourceTypeSet, EmptyCompoundTypeSet) + { + } + + 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 +95,33 @@ 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? FindCompoundType(Type clrType) + { + ArgumentNullException.ThrowIfNull(clrType); + + return _compoundTypesByClrType.GetValueOrDefault(clrType); + } + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) { return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs new file mode 100644 index 0000000000..eaa22e5a92 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -0,0 +1,78 @@ +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 Cannot_create_compound_type_without_clr_type() + { + // Act + Action action = () => _ = new CompoundType(null!); + + // Assert + action.Should().ThrowExactly(); + } + + [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(typeof(TestAddress)); + 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(typeof(TestAddress)).Should().Be(compoundType); + resourceGraph.FindCompoundType(typeof(string)).Should().BeNull(); + + resourceGraph.GetCompoundType(typeof(TestAddress)).Should().Be(compoundType); + Action getUnknown = () => resourceGraph.GetCompoundType(typeof(string)); + getUnknown.Should().ThrowExactly() + .WithMessage("Compound type 'System.String' does not exist in the resource graph."); + } + + private sealed class TestAddress + { + public string? Street { get; set; } + } +} From 8b9459c2dc79e369ed934ae0b5672aabe4cf0634 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:46:27 +0200 Subject: [PATCH 04/63] Update AttrAttribute with compound metadata Co-authored-by: Cursor --- .../Resources/Annotations/AttrAttribute.cs | 32 +++++++++++- .../Annotations/AttrAttribute.netstandard.cs | 3 ++ .../ResourceFieldAttributeTests.cs | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 26a660775a..026b798306 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Resources.Annotations; @@ -9,6 +10,7 @@ namespace JsonApiDotNetCore.Resources.Annotations; [AttributeUsage(AttributeTargets.Property)] public sealed class AttrAttribute : ResourceFieldAttribute { + private readonly Lazy _isCollectionLazy; private AttrCapabilities? _capabilities; internal bool HasExplicitCapabilities => _capabilities != null; @@ -31,6 +33,32 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// + /// Indicates whether this attribute is a complex/compound object. Defaults to false. + /// + public bool IsCompound { get; set; } + + /// + /// Evaluates whether the underlying property is a collection. + /// Non-settable; evaluated on demand from . + /// + public bool IsCollection => _isCollectionLazy.Value; + + /// + /// The schema of the nested compound type when is true; otherwise null. + /// + public CompoundType? CompoundType { get; internal set; } + + public AttrAttribute() + { + _isCollectionLazy = new Lazy(EvaluateIsCollection, LazyThreadSafetyMode.PublicationOnly); + } + + private bool EvaluateIsCollection() + { + return Property.PropertyType != typeof(string) && CollectionConverter.Instance.FindCollectionElementType(Property.PropertyType) != null; + } + /// public override bool Equals(object? obj) { @@ -46,12 +74,12 @@ public override bool Equals(object? obj) var other = (AttrAttribute)obj; - return Capabilities == other.Capabilities && base.Equals(other); + return Capabilities == other.Capabilities && IsCompound == other.IsCompound && CompoundType == other.CompoundType && base.Equals(other); } /// public override int GetHashCode() { - return HashCode.Combine(Capabilities, base.GetHashCode()); + return HashCode.Combine(Capabilities, 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/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index b7a028bd6e..e2a9966a2f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Http; @@ -230,6 +231,57 @@ 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 scalarAttr = new AttrAttribute + { + Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.ScalarString))! + }; + var listAttr = new AttrAttribute + { + Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.StringList))! + }; + var setAttr = new AttrAttribute + { + Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.IntSet))! + }; + + // Assert + scalarAttr.IsCollection.Should().BeFalse(); + listAttr.IsCollection.Should().BeTrue(); + setAttr.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); + } + + 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] From f7287eccd85fe4e0ee6431321d96ad55c4dabb0e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:49:13 +0200 Subject: [PATCH 05/63] Update ResourceFieldAttribute: drop Type, add ContainingType, update LeftType Co-authored-by: Cursor --- .../Annotations/RelationshipAttribute.cs | 4 ++-- .../Annotations/ResourceFieldAttribute.cs | 21 ++++++++++++------- .../Configuration/ResourceGraphBuilder.cs | 11 ++++++---- .../Queries/Parsing/FilterParser.cs | 2 +- .../Scopes/ScopesAuthorizationFilter.cs | 3 ++- .../FieldChainPatternInheritanceMatchTests.cs | 2 +- .../UnitTests/Links/LinkInclusionTests.cs | 4 ++-- 7 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index b580f0576f..bac14e3f1f 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 declared. 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..f9b39efccc 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,7 @@ public virtual void SetValue(object resource, object? newValue) protected void AssertIsIdentifiable(object? resource) { - if (resource is not null and not IIdentifiable) + 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 +131,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/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 5f020cb4d9..43b940687d 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -50,7 +50,7 @@ public IResourceGraph Build() var resourceGraph = new ResourceGraph(resourceTypes); - SetFieldTypes(resourceGraph); + SetFieldContainingTypes(resourceGraph); SetRelationshipTypes(resourceGraph); SetDirectlyDerivedTypes(resourceGraph); ValidateFieldsInDerivedTypes(resourceGraph); @@ -58,11 +58,14 @@ 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()) { - field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!); + foreach (ResourceFieldAttribute field in resourceType.Fields) + { + field.ContainingType = resourceType; + } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 3001c19296..1295447dc2 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -581,7 +581,7 @@ private ConstantValueConverter GetConstantValueConverterForAttribute(AttrAttribu { try { - return DeObfuscateStringId(attribute.Type, stringValue); + return DeObfuscateStringId((ResourceType)attribute.ContainingType, stringValue); } catch (JsonApiException exception) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs index a4cba72878..ce28253b30 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; @@ -89,7 +90,7 @@ public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpres } else { - _authScopeSet.Include(field.Type, Permission.Read); + _authScopeSet.Include((ResourceType)field.ContainingType, Permission.Read); } } 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/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 From d648ccf624d29aae1b8427cea036ae5191cab611 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:51:29 +0200 Subject: [PATCH 06/63] ResourceGraphBuilder: Recursive discovery of CompoundType and children Co-authored-by: Cursor --- .../Configuration/ResourceGraphBuilder.cs | 89 ++++++++++++++++++- .../ResourceGraphBuilderTests.cs | 77 ++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 43b940687d..3e06cf3202 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -19,6 +19,7 @@ public 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,19 +37,22 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { + DiscoverCompoundTypes(); + 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); SetFieldContainingTypes(resourceGraph); SetRelationshipTypes(resourceGraph); @@ -67,6 +71,14 @@ private static void SetFieldContainingTypes(ResourceGraph resourceGraph) field.ContainingType = resourceType; } } + + foreach (CompoundType compoundType in resourceGraph.GetCompoundTypes()) + { + foreach (AttrAttribute attribute in compoundType.Attributes) + { + attribute.ContainingType = compoundType; + } + } } private static void SetRelationshipTypes(ResourceGraph resourceGraph) @@ -350,6 +362,81 @@ 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); + _nonIdentifiableTypes.Remove(compoundClrType); + + if (_compoundTypesByClrType.TryGetValue(compoundClrType, out CompoundType? existingCompoundType)) + { + return existingCompoundType; + } + + var attributesByName = new Dictionary(); + + foreach (PropertyInfo property in compoundClrType.GetProperties()) + { + var attribute = property.GetCustomAttribute(true); + if (attribute != null) + { + SetPublicName(attribute, property); + attribute.Property = property; + + if (!attribute.HasExplicitCapabilities) + { + attribute.Capabilities = _options.DefaultAttrCapabilities; + } + + if (attribute.IsCompound) + { + 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 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/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index a12af46e1b..ae6c01659a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -562,6 +562,83 @@ public sealed class DerivedAlternateToOneRelationshipName : AbstractBaseResource public override AbstractBaseResource? BaseToOne { get; set; } } + [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(typeof(TestRootCompound)); + 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(typeof(TestChildCompound)); + 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(typeof(TestPhoneCompound)); + + resourceGraph.GetCompoundTypes().Should().HaveCount(3); + resourceGraph.GetCompoundType(typeof(TestRootCompound)).Should().Be(rootCompoundType); + resourceGraph.GetCompoundType(typeof(TestChildCompound)).Should().Be(nameCompoundType); + resourceGraph.GetCompoundType(typeof(TestPhoneCompound)).Should().Be(phonesAttr.CompoundType); + } + + [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 { From bb71a3dc505d2af4fb1364f0b1313cf7fdb4a9a1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:56:07 +0200 Subject: [PATCH 07/63] Validate compound types and their attributes in ResourceGraphBuilder Co-authored-by: Cursor --- .../Configuration/ResourceGraphBuilder.cs | 83 +++++ .../JsonApiDotNetCoreTests.csproj | 1 + .../ResourceGraphBuilderTests.cs | 295 ++++++++++++++++++ 3 files changed, 379 insertions(+) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 3e06cf3202..c957e1a14f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -38,6 +38,7 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor public IResourceGraph Build() { DiscoverCompoundTypes(); + AssertNoCyclesInCompoundTypes(); foreach (Type nonIdentifiableType in _nonIdentifiableTypes) { @@ -255,6 +256,11 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st return this; } + if (_compoundTypesByClrType.ContainsKey(resourceClrType)) + { + throw new InvalidConfigurationException($"Type '{resourceClrType}' cannot be both a resource type and a compound type."); + } + if (resourceClrType.IsOrImplementsInterface()) { string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); @@ -351,6 +357,11 @@ private Dictionary.ValueCollection GetAttributes(Type res attribute.Capabilities = _options.DefaultAttrCapabilities; } + if (attribute.IsCompound) + { + attribute.Capabilities &= ~AttrCapabilities.AllowSort; + } + IncludeField(attributesByName, attribute); } @@ -388,6 +399,20 @@ private void DiscoverCompoundTypes() 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)) @@ -395,10 +420,29 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu 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) { @@ -412,6 +456,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu if (attribute.IsCompound) { + attribute.Capabilities &= ~AttrCapabilities.AllowSort; pendingAttributes.Enqueue(attribute); } @@ -429,6 +474,44 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu 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.IsCompound && attribute.CompoundType != null) + { + AssertNoCyclesInCompoundTypes(attribute.CompoundType.ClrType, visited, inPath); + } + } + + inPath.Remove(currentType); + } + private static Type GetCompoundClrType(PropertyInfo property, bool isCollection) { Type propertyType = property.PropertyType; diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index edbb9aae48..97980721ab 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,6 +1,7 @@ net10.0 + $(NoWarn);JADNC001 diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index ae6c01659a..13aadc4b2e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -608,6 +609,300 @@ public void Discovers_compound_types_recursively() resourceGraph.GetCompoundType(typeof(TestPhoneCompound)).Should().Be(phonesAttr.CompoundType); } + [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(CompoundWithResourceAttribute)}' 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(typeof(DerivedCompound)).Should().NotBeNull(); + CompoundType compoundType = resourceGraph.GetCompoundType(typeof(DerivedCompound)); + compoundType.Attributes.Should().ContainSingle(attr => attr.Property.Name == nameof(DerivedCompound.Value)); + } + + [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; } + } + + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] + public sealed class CompoundWithResourceAttribute + { + [Attr] + public string? Text { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithAnnotatedCompound : Identifiable + { + [Attr(IsCompound = true)] + public CompoundWithResourceAttribute? 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 { From d2566d08d66bc74908a6024d92709e0ce2789d58 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:59:59 +0200 Subject: [PATCH 08/63] Implement 6-token grammar (O, M, A, C, P, Q) in FieldTypes & FieldChainPattern Co-authored-by: Cursor --- .../FieldChains/FieldChainPattern.cs | 27 +++++++++++++++++++ .../QueryStrings/FieldChains/FieldTypes.cs | 10 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index e3c15ebdbe..cd4a2ad755 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -11,6 +11,15 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; [PublicAPI] public sealed class FieldChainPattern { + public const char ToManyRelationshipToken = 'M'; + public const char ToOneRelationshipToken = 'O'; + public const char RelationshipToken = 'R'; + public const char AttributeToken = 'A'; + public const char CompoundAttributeToken = 'C'; + public const char PrimitiveCollectionToken = 'P'; + public const char CompoundCollectionToken = 'Q'; + public const char FieldToken = 'F'; + /// /// Gets the set of possible resource field types. /// @@ -78,6 +87,24 @@ internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, /// /// /// + /// C + /// + /// Matches a compound attribute. + /// + /// + /// + /// P + /// + /// Matches a primitive collection. + /// + /// + /// + /// Q + /// + /// Matches a compound collection. + /// + /// + /// /// F /// /// Matches a field. diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs index 8011ec3ec4..e2e0ab1fb9 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -4,9 +4,15 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; internal enum FieldTypes { None = 0, - Attribute = 1, + ScalarAttribute = 1, ToOneRelationship = 1 << 1, ToManyRelationship = 1 << 2, + CompoundAttribute = 1 << 3, + PrimitiveCollection = 1 << 4, + CompoundCollection = 1 << 5, + Relationship = ToOneRelationship | ToManyRelationship, - Field = Attribute | Relationship + Field = ScalarAttribute | Relationship | CompoundAttribute | PrimitiveCollection | CompoundCollection, + + Attribute = ScalarAttribute } From b7d74f104c896ec46e1c1b7f9ec415b22a33821c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:02:11 +0200 Subject: [PATCH 09/63] Update PatternParser and PatternTextFormatter for compound tokens Co-authored-by: Cursor --- .../FieldChains/FieldTypeExtensions.cs | 26 +++++++++++++++---- .../QueryStrings/FieldChains/PatternParser.cs | 15 ++++++++--- .../FieldChains/PatternTextFormatter.cs | 26 +++++++++++++++---- .../FieldChainPatternParseTests.cs | 17 ++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs index 8c18c4448e..8dd35dc121 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -8,6 +8,12 @@ public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool { int startOffset = builder.Length; + if (choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("field", pluralize, prefix, false, builder, startOffset); + return; + } + if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) { WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); @@ -18,19 +24,29 @@ public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); } - if (choices.HasFlag(FieldTypes.Attribute) && !choices.HasFlag(FieldTypes.Relationship)) + if (choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.ScalarAttribute)) { WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); } - if (choices.HasFlag(FieldTypes.Relationship) && !choices.HasFlag(FieldTypes.Field)) + if (choices.HasFlag(FieldTypes.CompoundAttribute)) { - WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + WriteChoice("compound attribute", pluralize, prefix, false, builder, startOffset); } - if (choices.HasFlag(FieldTypes.Field)) + if (choices.HasFlag(FieldTypes.PrimitiveCollection)) { - WriteChoice("field", pluralize, prefix, false, builder, startOffset); + WriteChoice("primitive collection", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.CompoundCollection)) + { + WriteChoice("compound collection", pluralize, prefix, false, builder, startOffset); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs index c6e05fdd4f..9026965c4a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -15,7 +15,10 @@ internal sealed class PatternParser ['M'] = Token.ToManyRelationship, ['O'] = Token.ToOneRelationship, ['R'] = Token.Relationship, - ['A'] = Token.Attribute, + ['A'] = Token.ScalarAttribute, + ['C'] = Token.CompoundAttribute, + ['P'] = Token.PrimitiveCollection, + ['Q'] = Token.CompoundCollection, ['F'] = Token.Field }; @@ -24,7 +27,10 @@ internal sealed class PatternParser [Token.ToManyRelationship] = FieldTypes.ToManyRelationship, [Token.ToOneRelationship] = FieldTypes.ToOneRelationship, [Token.Relationship] = FieldTypes.Relationship, - [Token.Attribute] = FieldTypes.Attribute, + [Token.ScalarAttribute] = FieldTypes.ScalarAttribute, + [Token.CompoundAttribute] = FieldTypes.CompoundAttribute, + [Token.PrimitiveCollection] = FieldTypes.PrimitiveCollection, + [Token.CompoundCollection] = FieldTypes.CompoundCollection, [Token.Field] = FieldTypes.Field }; @@ -180,7 +186,10 @@ private enum Token ToManyRelationship, ToOneRelationship, Relationship, - Attribute, + ScalarAttribute, + CompoundAttribute, + PrimitiveCollection, + CompoundCollection, Field } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs index 2fec95b900..b85a4c773a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -35,6 +35,12 @@ public string Format() private static void WriteChoices(FieldTypes types, StringBuilder builder) { + if (types.HasFlag(FieldTypes.Field)) + { + builder.Append('F'); + return; + } + int startOffset = builder.Length; if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) @@ -47,19 +53,29 @@ private static void WriteChoices(FieldTypes types, StringBuilder builder) builder.Append('O'); } - if (types.HasFlag(FieldTypes.Attribute) && !types.HasFlag(FieldTypes.Relationship)) + if (types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('R'); + } + + if (types.HasFlag(FieldTypes.ScalarAttribute)) { builder.Append('A'); } - if (types.HasFlag(FieldTypes.Relationship) && !types.HasFlag(FieldTypes.Field)) + if (types.HasFlag(FieldTypes.CompoundAttribute)) { - builder.Append('R'); + builder.Append('C'); } - if (types.HasFlag(FieldTypes.Field)) + if (types.HasFlag(FieldTypes.PrimitiveCollection)) { - builder.Append('F'); + builder.Append('P'); + } + + if (types.HasFlag(FieldTypes.CompoundCollection)) + { + builder.Append('Q'); } int charCount = builder.Length - startOffset; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs index d7e5c22cc1..73410b060d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -18,6 +18,17 @@ public sealed class FieldChainPatternParseTests [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("O?M+A", "an optional to-one relationship, followed by one or more to-many relationships, followed by an attribute")] + [InlineData("C", "a compound attribute")] + [InlineData("P", "a primitive collection")] + [InlineData("Q", "a compound collection")] + [InlineData("[OC]", "a to-one relationship or a compound attribute")] + [InlineData("[MPQ]", "a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[OC]*A", "zero or more to-one relationships or compound attributes, followed by an attribute")] + [InlineData("[OC]*[MPQ]", "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[OC]*[OAC]", "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute")] + [InlineData("C*A", "zero or more compound attributes, followed by an attribute")] + [InlineData("C+A", "one or more compound attributes, followed by an attribute")] + [InlineData("[OC]?", "an optional to-one relationship or compound attribute")] public void ParseSucceeds(string patternText, string displayText) { // Act @@ -61,6 +72,12 @@ public void ParseFails(string patternText, string errorMessage) [InlineData("[MOR]", "R", "a relationship")] [InlineData("[MORAF]", "F", "a field")] [InlineData("[AMAM]", "[MA]", "a to-many relationship or an attribute")] + [InlineData("[C]", "C", "a compound attribute")] + [InlineData("[P]", "P", "a primitive collection")] + [InlineData("[Q]", "Q", "a compound collection")] + [InlineData("[OCOC]", "[OC]", "a to-one relationship or a compound attribute")] + [InlineData("[QPMP]", "[MPQ]", "a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[MORACPQ]", "F", "a field")] public void ParseNormalizesChoices(string patternText, string reducedText, string displayText) { // Act From 70f1fcbf46b63a8b3ebf2fb4e1511487c4784aff Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:08:15 +0200 Subject: [PATCH 10/63] Update PatternMatcher and MatchState to traverse IAttributeContainer Co-authored-by: Cursor --- .../FieldChains/FieldChainPattern.cs | 28 ++++- .../QueryStrings/FieldChains/MatchError.cs | 22 ++-- .../QueryStrings/FieldChains/MatchState.cs | 48 +++++--- .../FieldChains/PatternMatcher.cs | 29 ++++- .../FieldChainPatternMatchTests.cs | 114 ++++++++++++++++-- 5 files changed, 203 insertions(+), 38 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index cd4a2ad755..16eaf0ec6d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -170,13 +170,37 @@ public static FieldChainPattern Parse(string pattern) /// public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, ILoggerFactory? loggerFactory = null) + { + return Match(fieldChain, (IAttributeContainer)resourceType, options, loggerFactory); + } + + /// + /// Matches the specified resource field chain against this pattern, starting from the specified container. + /// + /// + /// The dot-separated chain of resource field names. + /// + /// + /// The parent attribute container to start matching from. + /// + /// + /// Match options, defaults to . + /// + /// + /// When provided, logs the matching steps at level. + /// + /// + /// The match result. + /// + 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/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs index 3707151560..83b8fcfbec 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,12 @@ 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) { var builder = new MessageBuilder(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); builder.WriteExpected(); builder.WriteEnd(); @@ -69,7 +69,7 @@ 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) { var builder = new MessageBuilder(); @@ -79,7 +79,7 @@ public static MatchError CreateForTooMuchInput(int position, ResourceType? resou { builder.WriteOr(); builder.WriteChoices(choices); - builder.WriteResourceType(resourceType); + builder.WriteContainer(container); } builder.WriteExpected(); @@ -132,12 +132,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..758230f641 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -20,10 +20,15 @@ internal sealed class MatchState /// public FieldChainPattern? Pattern { get; } + /// + /// The container to find the next field on. + /// + public IAttributeContainer? Container { get; } + /// /// The resource type to find the next field on. /// - public ResourceType? ResourceType { get; } + public ResourceType? ResourceType => Container as ResourceType; /// /// 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, IsCollection: false } 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..50cc0b89ec 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -24,11 +24,16 @@ public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions o } public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + { + return Match(fieldChain, (IAttributeContainer)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 +160,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 +189,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 +220,15 @@ private HashSet LookupFields(ResourceType? resourceType, } } } + else if (container != null) + { + AttrAttribute? attribute = container.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + } return fields; } @@ -233,7 +247,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.CompoundCollection, + AttrAttribute { IsCompound: true, IsCollection: false } => FieldTypes.CompoundAttribute, + AttrAttribute { IsCompound: false, IsCollection: true } => FieldTypes.PrimitiveCollection, + AttrAttribute => FieldTypes.ScalarAttribute, null => FieldTypes.None, _ => FieldTypes.Field }; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 0325f9d728..15bf27860c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -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 P = "tags"; + private const string Q = "items"; + private const string A2 = "title"; + private const string C2 = "nested"; + private const string A3 = "description"; + private const string P2 = "numbers"; private readonly LoggerFactory _loggerFactory; private readonly ResourceType _resourceType; @@ -91,6 +98,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("P", P)] + [InlineData("Q", Q)] + [InlineData("F", C)] + [InlineData("F", P)] + [InlineData("F", Q)] + [InlineData("CA", $"{C}.{A2}")] + [InlineData("CCA", $"{C}.{C2}.{A3}")] + [InlineData("OCA", $"{O}.{C}.{A2}")] + [InlineData("OCCA", $"{O}.{C}.{C2}.{A3}")] + [InlineData("CP", $"{C}.{P2}")] + [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("[MPQ]", M)] + [InlineData("[MPQ]", P)] + [InlineData("[MPQ]", Q)] + [InlineData("[OC]*[MPQ]", M)] + [InlineData("[OC]*[MPQ]", P)] + [InlineData("[OC]*[MPQ]", Q)] + [InlineData("[OC]*[MPQ]", $"{C}.{P2}")] + [InlineData("[OC]*[MPQ]", $"{C}.{Q}")] + [InlineData("[OC]*[MPQ]", $"{O}.{M}")] + [InlineData("[OC]*[MPQ]", $"{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}")] @@ -181,10 +225,10 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("A", $"^{M}", $"Attribute on resource type '{T}' expected.")] [InlineData("A", $"^{O}", $"Attribute on resource type '{T}' expected.")] // Field sets: insufficient input - [InlineData("[AR]", "^", $"Field on resource type '{T}' expected.")] - [InlineData("[MO][AR]", $"{M}^", $"Field on resource type '{T}' expected.")] - [InlineData("[AR][AR]", $"{A}^", "Field expected.")] - [InlineData("[MO][MO][AR]", $"{O}.{M}^", $"Field on resource type '{T}' expected.")] + [InlineData("[AR]", "^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("[MO][AR]", $"{M}^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("[AR][AR]", $"{A}^", "Relationship or attribute expected.")] + [InlineData("[MO][MO][AR]", $"{O}.{M}^", $"Relationship or attribute on resource type '{T}' expected.")] [InlineData("[MA]", "^", $"To-many relationship or attribute on resource type '{T}' expected.")] [InlineData("[MO][MO]", $"{M}^", $"Relationship on resource type '{T}' expected.")] [InlineData("[MO][MO][MA]", $"{M}.{O}^", $"To-many relationship or attribute on resource type '{T}' expected.")] @@ -218,10 +262,10 @@ public void MatchSucceeds(string patternText, string fieldChainText) [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.")] - [InlineData("M*O*A", "^", $"Field on resource type '{T}' expected.")] + [InlineData("M*O*A", "^", $"Relationship or attribute on resource type '{T}' expected.")] [InlineData("M*O*A", $"{M}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] [InlineData("M*O*A", $"{M}.{M}.{O}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] - [InlineData("M?O?A", "^", $"Field on resource type '{T}' expected.")] + [InlineData("M?O?A", "^", $"Relationship or attribute on resource type '{T}' expected.")] [InlineData("M?O?A", $"{M}.{O}^", $"Attribute on resource type '{T}' expected.")] [InlineData("M+O+A", "^", $"To-many relationship on resource type '{T}' expected.")] [InlineData("M+O+A", $"{M}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] @@ -364,7 +408,7 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("M+O", $"{M}.^{A}", $"Relationship on resource type '{T}' expected.")] [InlineData("M+OA", $"{M}^", $"Relationship on resource type '{T}' expected.")] [InlineData("O+A", $"{O}.^{M}", $"To-one relationship or attribute on resource type '{T}' expected.")] - [InlineData("[MA]+O", $"{M}^", $"Field on resource type '{T}' expected.")] + [InlineData("[MA]+O", $"{M}^", $"Relationship or attribute on resource type '{T}' expected.")] // Quantifiers at start and at end [InlineData("M*OA*", "^", $"Relationship on resource type '{T}' expected.")] [InlineData("M*OA*", $"{O}.{A}.^{A}", $"Field '{A}' does not exist.")] @@ -387,6 +431,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("A", $"^{C}", $"Attribute on resource type '{T}' expected.")] + [InlineData("A", $"^{P}", $"Attribute on resource type '{T}' expected.")] + [InlineData("A", $"^{Q}", $"Attribute on resource type '{T}' expected.")] + [InlineData("C", $"^{A}", $"Compound attribute on resource type '{T}' expected.")] + [InlineData("C", $"^{P}", $"Compound attribute on resource type '{T}' expected.")] + [InlineData("P", $"^{A}", $"Primitive collection on resource type '{T}' expected.")] + [InlineData("P", $"^{Q}", $"Primitive collection on resource type '{T}' expected.")] + [InlineData("Q", $"^{P}", $"Compound collection on resource type '{T}' expected.")] + // Navigation on compound type + [InlineData("CA", $"{C}.^{C2}", "Attribute on compound type 'CompoundRoot' expected.")] + [InlineData("CA", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] + [InlineData("CCA", $"{C}.{C2}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundNested'.")] + // Too much input on compound type + [InlineData("C", $"{C}.^{A2}", "End of field chain expected.")] + // Direct navigation into collection rejected + [InlineData("[OC]*A", $"{C}.^{Q}", "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] public void MatchFails(string patternText, string fieldChainText, string failureMessage) { // Arrange @@ -419,5 +480,44 @@ private sealed class Resource : Identifiable [HasMany] public ISet Children { get; set; } = new HashSet(); + + [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(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; } } } From fbcd044db3345d3c747cbb5fd008459cd24afce3 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:17:04 +0200 Subject: [PATCH 11/63] Support compound attribute and collection scoping in FilterParser Co-authored-by: Cursor --- .../Queries/Parsing/FilterParser.cs | 94 ++++++++---- .../Queries/Parsing/QueryExpressionParser.cs | 19 ++- .../FieldChains/BuiltInPatterns.cs | 4 + .../CompoundFilterParseTests.cs | 141 ++++++++++++++++++ .../QueryStringParameters/FilterParseTests.cs | 12 +- .../LegacyFilterParseTests.cs | 12 +- 6 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 1295447dc2..a4fc44a25f 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -34,7 +34,23 @@ public class FilterParser : QueryExpressionParser, IFilterParser ]; private readonly IResourceFactory _resourceFactory; - private readonly Stack _resourceTypeStack = new(); + private readonly Stack _containerStack = new(); + + /// + /// Gets the container currently in scope. Call to temporarily change the current container. + /// + protected IAttributeContainer ContainerInScope + { + get + { + if (_containerStack.Count == 0) + { + throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + } + + return _containerStack.Peek(); + } + } /// /// Gets the resource type currently in scope. Call to temporarily change the current resource type. @@ -43,12 +59,12 @@ protected ResourceType ResourceTypeInScope { get { - if (_resourceTypeStack.Count == 0) + if (ContainerInScope is ResourceType resourceType) { - throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + return resourceType; } - return _resourceTypeStack.Peek(); + throw new InvalidOperationException($"The container currently in scope is not a resource type: '{ContainerInScope}'."); } } @@ -66,7 +82,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); - _resourceTypeStack.Clear(); + _containerStack.Clear(); FilterExpression expression; using (InScopeOfResourceType(resourceType)) @@ -76,7 +92,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) AssertTokenStackIsEmpty(); } - AssertResourceTypeStackIsEmpty(); + AssertContainerStackIsEmpty(); return expression; } @@ -110,7 +126,7 @@ private CountExpression ParseCount() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.None, ContainerInScope, null); EatSingleCharacterToken(TokenKind.CloseParen); @@ -235,12 +251,12 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe return ParseFunction(); } - // Allow equality comparison of a to-one relationship with null. + // Allow equality comparison of a to-one relationship or compound attribute with null. FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals - ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne - : BuiltInPatterns.ToOneChainEndingInAttribute; + ? BuiltInPatterns.ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound + : BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute; - return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ContainerInScope, "Function or field name expected."); } private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) @@ -249,7 +265,7 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) { ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; - if (leftLastField is HasOneAttribute) + if (leftLastField is HasOneAttribute or AttrAttribute { IsCompound: true, IsCollection: false }) { return ParseNull(); } @@ -305,7 +321,7 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } - return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, errorMessage); } } @@ -349,8 +365,8 @@ private QueryExpression ParseTextMatchLeftTerm() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -404,7 +420,7 @@ protected virtual AnyExpression ParseAny() } ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -418,7 +434,7 @@ protected virtual HasExpression ParseHas() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.None, ContainerInScope, null); FilterExpression? filter = null; @@ -426,11 +442,21 @@ protected virtual HasExpression ParseHas() { 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 (InScopeOfResourceType(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + else if (lastField is AttrAttribute { IsCompound: true, IsCollection: true } compoundCollectionAttr) + { + using (InScopeOfContainer(compoundCollectionAttr.CompoundType!)) + { + filter = ParseFilter(); + } } } @@ -575,13 +601,13 @@ protected virtual ConstantValueConverter GetConstantValueConverterForType(Type d 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((ResourceType)attribute.ContainingType, stringValue); + return DeObfuscateStringId(resourceType, stringValue); } catch (JsonApiException exception) { @@ -616,28 +642,36 @@ protected override void ValidateField(ResourceFieldAttribute field, int position /// protected IDisposable InScopeOfResourceType(ResourceType resourceType) { - ArgumentNullException.ThrowIfNull(resourceType); + return InScopeOfContainer(resourceType); + } + + /// + /// Changes the container currently in scope and restores the original container when the return value is disposed. + /// + protected IDisposable InScopeOfContainer(IAttributeContainer container) + { + ArgumentNullException.ThrowIfNull(container); - _resourceTypeStack.Push(resourceType); - return new PopResourceTypeOnDispose(_resourceTypeStack); + _containerStack.Push(container); + return new PopContainerOnDispose(_containerStack); } - private void AssertResourceTypeStackIsEmpty() + private void AssertContainerStackIsEmpty() { - if (_resourceTypeStack.Count > 0) + if (_containerStack.Count > 0) { throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); } } - private sealed class PopResourceTypeOnDispose(Stack resourceTypeStack) : IDisposable + private sealed class PopContainerOnDispose(Stack containerStack) : IDisposable { - private readonly Stack _resourceTypeStack = resourceTypeStack; + private readonly Stack _containerStack = containerStack; public void Dispose() { - _resourceTypeStack.Pop(); + _containerStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index 46fa2c8273..c39c68e391 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -74,20 +74,33 @@ private static Stack TokensToStack(Token[] tokens) /// protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType, string? alternativeErrorMessage) + { + return ParseFieldChain(pattern, options, (IAttributeContainer)resourceType, alternativeErrorMessage); + } + + /// + /// 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, IAttributeContainer container, + string? alternativeErrorMessage) { ArgumentNullException.ThrowIfNull(pattern); - ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(container); int startPosition = GetNextTokenPositionOrEnd(); string path = EatFieldChain(alternativeErrorMessage); - PatternMatchResult result = pattern.Match(path, resourceType, options); + 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); } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 0ad7432005..05105b5811 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -11,6 +11,10 @@ public static class BuiltInPatterns public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); + public static FieldChainPattern ToOneOrCompoundChainEndingInAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); + public static FieldChainPattern ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound { get; } = FieldChainPattern.Parse("[OC]*[AOC]"); + public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MPQ]"); + [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"); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs new file mode 100644 index 0000000000..2e974066cd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -0,0 +1,141 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; + +public sealed class CompoundFilterParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public CompoundFilterParseTests() + { + var options = new JsonApiOptions { EnableLegacyFilterNotation = false }; + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add() + .Build(); + + var request = new JsonApiRequest + { + PrimaryResourceType = resourceGraph.GetResourceType(), + IsCollection = true + }; + + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new FilterParser(resourceFactory); + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, request, resourceGraph, options); + } + + [Theory] + [InlineData("filter", "equals(details.title,'abc')")] + [InlineData("filter", "equals(details,null)")] + [InlineData("filter", "not(equals(details,null))")] + [InlineData("filter", "equals(details.nested.description,'test')")] + [InlineData("filter", "has(items,equals(label,'work'))")] + [InlineData("filter", "has(details.items,equals(label,'work'))")] + [InlineData("filter", "greaterThan(count(items),'3')")] + [InlineData("filter", "greaterThan(count(details.items),'1')")] + [InlineData("filter", "greaterThan(count(tags),'0')")] + [InlineData("filter", "greaterThan(count(details.numbers),'2')")] + public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string parameterValue) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } + + [Theory] + [InlineData("filter", "greaterThan(details^,null)", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] + [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] + public void Reader_Read_Compound_Filter_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [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(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/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index d3ea46286f..864c5c035a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -100,8 +100,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter[posts]", "equals(author,^'some')", "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 attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on resource type 'webAccounts' expected.")] [InlineData("filter", "^ ", "Unexpected whitespace.")] [InlineData("filter", "contains(owner.displayName^ ,)", "Unexpected whitespace.")] [InlineData("filter", "contains(owner.displayName,^ )", "Unexpected whitespace.")] @@ -124,16 +124,16 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "contains(^equals(title,'x'),'a')", "Function that returns type 'String' 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.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on resource type 'webAccounts' expected.")] [InlineData("filter", "any(^null,'a','b')", "Field name expected.")] [InlineData("filter", "any(^'a','b','c')", "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(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 attributes, followed by an attribute. " + + "To-one relationship or attribute or compound 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.")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index eae586ebdb..4719147c01 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -36,8 +36,8 @@ public LegacyFilterParseTests() [InlineData("filter[]", "Expected field name between brackets 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. " + - "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 attributes, followed by a to-one relationship or an attribute or a compound attribute. " + + "To-one relationship or attribute or compound 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) { @@ -63,11 +63,11 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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: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 attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + + "Relationship or compound attribute or primitive collection or compound collection 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + + "Relationship or compound attribute or primitive collection or compound collection on resource type 'webAccounts' expected.")] public void Reader_Read_ParameterValue_Fails(string parameterName, string parameterValue, string errorMessage) { // Act From b81292e4f4fee7d77cca62644964c7eb79071c07 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:24:57 +0200 Subject: [PATCH 12/63] Introduce ElementValueExpression and parse '_' in primitive collection has() Co-authored-by: Cursor --- .../Expressions/ElementValueExpression.cs | 35 ++++ .../Expressions/QueryExpressionRewriter.cs | 5 + .../Expressions/QueryExpressionVisitor.cs | 5 + .../Queries/Parsing/FilterParser.cs | 180 ++++++++++++++++-- .../Queries/Parsing/Keywords.cs | 1 + .../CompoundFilterParseTests.cs | 34 ++++ 6 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs new file mode 100644 index 0000000000..f63a3778c2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the element value of a primitive collection in a collection filter (such as within ). +/// +[PublicAPI] +public sealed class ElementValueExpression : IdentifierExpression +{ + /// + /// The CLR type of the primitive collection element (e.g. , ). + /// + public Type ElementType { get; } + + public ElementValueExpression(Type elementType) + { + ArgumentNullException.ThrowIfNull(elementType); + ElementType = elementType; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitElementValue(this, argument); + } + + public override string ToString() => "_"; + + public override string ToFullString() => "_"; + + public override bool Equals(object? obj) => + ReferenceEquals(this, obj) || (obj is ElementValueExpression other && ElementType == other.ElementType); + + public override int GetHashCode() => ElementType.GetHashCode(); +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 2da39bb2fc..a034b7193b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -51,6 +51,11 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } + public override QueryExpression VisitElementValue(ElementValueExpression expression, TArgument argument) + { + return expression; + } + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) { IImmutableList newTerms = VisitList(expression.Terms, argument); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index a0472306f7..494f25f4ce 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 VisitElementValue(ElementValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument) { return DefaultVisit(expression, argument); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index a4fc44a25f..d8165f6982 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -35,6 +35,12 @@ public class FilterParser : QueryExpressionParser, IFilterParser private readonly IResourceFactory _resourceFactory; private readonly Stack _containerStack = new(); + private readonly Stack _primitiveElementStack = new(); + + /// + /// Gets the CLR type of the primitive collection element currently in scope, or null if not in scope of a primitive collection. + /// + protected Type? PrimitiveElementInScope => _primitiveElementStack.Count > 0 ? _primitiveElementStack.Peek() : null; /// /// Gets the container currently in scope. Call to temporarily change the current container. @@ -83,6 +89,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); _containerStack.Clear(); + _primitiveElementStack.Clear(); FilterExpression expression; using (InScopeOfResourceType(resourceType)) @@ -93,6 +100,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) } AssertContainerStackIsEmpty(); + AssertPrimitiveElementStackIsEmpty(); return expression; } @@ -122,6 +130,12 @@ protected virtual FunctionExpression ParseFunction() private CountExpression ParseCount() { + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'count' cannot be used on primitive collection elements.", position); + } + EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); @@ -246,9 +260,29 @@ protected virtual ComparisonExpression ParseComparison(string operatorName) private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator) { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) { - return ParseFunction(); + if (IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + if (PrimitiveElementInScope != null) + { + if (nextToken.Value == Keywords.Element) + { + EatText(Keywords.Element); + return new ElementValueExpression(PrimitiveElementInScope); + } + + throw new QueryParseException("Function or '_' expected.", nextToken.Position); + } + } + + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function or '_' expected.", position); } // Allow equality comparison of a to-one relationship or compound attribute with null. @@ -261,6 +295,12 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) { + if (leftTerm is ElementValueExpression elementValue) + { + ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(elementValue.ElementType); + return ParseTypedComparisonRightTerm(elementValue.ElementType, constantValueConverter); + } + if (leftTerm is ResourceFieldChainExpression leftFieldChain) { ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; @@ -283,15 +323,16 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) } throw new InvalidOperationException( - $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + $"Internal error: Expected left term to be a function, field chain or element value, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); } private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter) { bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); - string errorMessage = - allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; + string errorMessage = PrimitiveElementInScope != null + ? (allowNull ? "Function, value between quotes, null or '_' expected." : "Function, value between quotes or '_' expected.") + : (allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."); if (TokenStack.TryPeek(out Token? nextToken)) { @@ -321,6 +362,17 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } + if (PrimitiveElementInScope != null) + { + if (nextToken.Value == Keywords.Element) + { + EatText(Keywords.Element); + return new ElementValueExpression(PrimitiveElementInScope); + } + + throw new QueryParseException(errorMessage, nextToken.Position); + } + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, errorMessage); } } @@ -351,16 +403,42 @@ protected virtual MatchTextExpression ParseTextMatch(string operatorName) private QueryExpression ParseTextMatchLeftTerm() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) { - FunctionExpression targetFunction = ParseFunction(); + if (IsFunction(nextToken.Value!)) + { + FunctionExpression targetFunction = ParseFunction(); + + if (targetFunction.ReturnType != typeof(string)) + { + throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); + } + + return targetFunction; + } - if (targetFunction.ReturnType != typeof(string)) + if (PrimitiveElementInScope != null) { - throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); + if (nextToken.Value == Keywords.Element) + { + EatText(Keywords.Element); + + if (PrimitiveElementInScope != typeof(string)) + { + throw new QueryParseException("Element of type 'String' expected.", nextToken.Position); + } + + return new ElementValueExpression(PrimitiveElementInScope); + } + + throw new QueryParseException("Function or '_' expected.", nextToken.Position); } + } - return targetFunction; + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function or '_' expected.", position); } int chainStartPosition = GetNextTokenPositionOrEnd(); @@ -411,14 +489,36 @@ protected virtual AnyExpression ParseAny() private (QueryExpression matchTarget, Func constantValueConverterFactory) ParseAnyLeftTerm() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) { - FunctionExpression targetFunction = ParseFunction(); + if (IsFunction(nextToken.Value!)) + { + FunctionExpression targetFunction = ParseFunction(); - Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); - return (targetFunction, functionConverterFactory); + Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); + return (targetFunction, functionConverterFactory); + } + + if (PrimitiveElementInScope != null) + { + if (nextToken.Value == Keywords.Element) + { + EatText(Keywords.Element); + return (new ElementValueExpression(PrimitiveElementInScope), () => GetConstantValueConverterForType(PrimitiveElementInScope)); + } + + throw new QueryParseException("Function or '_' expected.", nextToken.Position); + } } + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function or '_' expected.", position); + } + + int chainStartPosition = GetNextTokenPositionOrEnd(); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); @@ -430,6 +530,12 @@ protected virtual AnyExpression ParseAny() protected virtual HasExpression ParseHas() { + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'has' cannot be used on primitive collection elements.", position); + } + EatText(Keywords.Has); EatSingleCharacterToken(TokenKind.OpenParen); @@ -458,6 +564,16 @@ protected virtual HasExpression ParseHas() filter = ParseFilter(); } } + else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } primitiveCollectionAttr) + { + Type elementType = CollectionConverter.Instance.FindCollectionElementType(primitiveCollectionAttr.Property.PropertyType) + ?? throw new InvalidOperationException($"Cannot determine element type for primitive collection attribute '{primitiveCollectionAttr.Property.Name}'."); + + using (InScopeOfPrimitiveElement(elementType)) + { + filter = ParseFilter(); + } + } } EatSingleCharacterToken(TokenKind.CloseParen); @@ -467,6 +583,12 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { + if (PrimitiveElementInScope != null) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'isType' cannot be used on primitive collection elements.", position); + } + EatText(Keywords.IsType); EatSingleCharacterToken(TokenKind.OpenParen); @@ -656,6 +778,17 @@ protected IDisposable InScopeOfContainer(IAttributeContainer container) return new PopContainerOnDispose(_containerStack); } + /// + /// Changes the primitive element type currently in scope and restores the original type when the return value is disposed. + /// + protected IDisposable InScopeOfPrimitiveElement(Type elementType) + { + ArgumentNullException.ThrowIfNull(elementType); + + _primitiveElementStack.Push(elementType); + return new PopPrimitiveElementOnDispose(_primitiveElementStack); + } + private void AssertContainerStackIsEmpty() { if (_containerStack.Count > 0) @@ -665,6 +798,15 @@ private void AssertContainerStackIsEmpty() } } + private void AssertPrimitiveElementStackIsEmpty() + { + if (_primitiveElementStack.Count > 0) + { + throw new InvalidOperationException("There is still a primitive element type in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfPrimitiveElement)}()."); + } + } + private sealed class PopContainerOnDispose(Stack containerStack) : IDisposable { private readonly Stack _containerStack = containerStack; @@ -674,4 +816,14 @@ public void Dispose() _containerStack.Pop(); } } + + private sealed class PopPrimitiveElementOnDispose(Stack primitiveElementStack) : IDisposable + { + private readonly Stack _primitiveElementStack = primitiveElementStack; + + public void Dispose() + { + _primitiveElementStack.Pop(); + } + } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs index f39fcf8227..5eed05c293 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs @@ -21,4 +21,5 @@ public static class Keywords public const string Count = "count"; public const string Has = "has"; public const string IsType = "isType"; + public const string Element = "_"; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index 2e974066cd..e59d6293bf 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -55,6 +55,17 @@ public CompoundFilterParseTests() [InlineData("filter", "greaterThan(count(details.items),'1')")] [InlineData("filter", "greaterThan(count(tags),'0')")] [InlineData("filter", "greaterThan(count(details.numbers),'2')")] + [InlineData("filter", "has(tags)")] + [InlineData("filter", "has(tags,equals(_,'work'))")] + [InlineData("filter", "has(tags,startsWith(_,'w'))")] + [InlineData("filter", "has(tags,contains(_,'o'))")] + [InlineData("filter", "has(tags,endsWith(_,'k'))")] + [InlineData("filter", "has(tags,any(_,'work'))")] + [InlineData("filter", "has(tags,not(equals(_,'spam')))")] + [InlineData("filter", "has(tags,equals(_,null))")] + [InlineData("filter", "has(details.numbers)")] + [InlineData("filter", "has(details.numbers,equals(_,'42'))")] + [InlineData("filter", "has(details.numbers,greaterThan(_,'10'))")] public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string parameterValue) { // Act @@ -72,6 +83,11 @@ public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string pa "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] + [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or '_' expected.")] + [InlineData("filter", "has(tags,equals(^unknown,'work'))", "Function or '_' expected.")] + [InlineData("filter", "has(tags,^has(tags,equals(_,'w')))", "Function 'has' cannot be used on primitive collection elements.")] + [InlineData("filter", "has(tags,equals(^count(tags),'1'))", "Function 'count' cannot be used on primitive collection elements.")] + [InlineData("filter", "has(tags,^isType(,some))", "Function 'isType' cannot be used on primitive collection elements.")] public void Reader_Read_Compound_Filter_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -93,6 +109,24 @@ public void Reader_Read_Compound_Filter_Fails(string parameterName, string param error.Source.Parameter.Should().Be(parameterName); } + [Fact] + public void ElementValueExpression_Properties_And_Equality() + { + var expr1 = new ElementValueExpression(typeof(string)); + var expr2 = new ElementValueExpression(typeof(string)); + var expr3 = new ElementValueExpression(typeof(int)); + + expr1.ElementType.Should().Be(typeof(string)); + expr1.ToString().Should().Be("_"); + expr1.ToFullString().Should().Be("_"); + expr1.Should().Be(expr2); + expr1.GetHashCode().Should().Be(expr2.GetHashCode()); + expr1.Should().NotBe(expr3); + + Action createWithNull = () => _ = new ElementValueExpression(null!); + createWithNull.Should().ThrowExactly(); + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class CompoundModel : Identifiable { From e5585123e393eda02c549897d987c27b4e09da2e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:28:13 +0200 Subject: [PATCH 13/63] SortParser: Enforce [OC]*A and reject sorting on compound attributes or collections Co-authored-by: Cursor --- .../Queries/Parsing/SortParser.cs | 22 ++- .../CompoundSortParseTests.cs | 142 ++++++++++++++++++ .../QueryStringParameters/SortParseTests.cs | 12 +- 3 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index 814a3e4c9e..29be56c42e 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -91,7 +91,7 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy else { string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; - target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); } return new SortElementExpression(target, isAscending); @@ -123,13 +123,26 @@ protected virtual FunctionExpression ParseFunction(ResourceType resourceType) throw new QueryParseException("Count function expected.", position); } + private bool _isInsideCount; + private CountExpression ParseCount(ResourceType resourceType) { EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + _isInsideCount = true; + + ResourceFieldChainExpression targetCollection; + + try + { + targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, + FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + } + finally + { + _isInsideCount = false; + } EatSingleCharacterToken(TokenKind.CloseParen); @@ -140,7 +153,8 @@ protected override void ValidateField(ResourceFieldAttribute field, int position { ArgumentNullException.ThrowIfNull(field); - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + if (!_isInsideCount && 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/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs new file mode 100644 index 0000000000..358c4947d3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; + +public sealed class CompoundSortParseTests +{ + private readonly SortQueryStringParameterReader _reader; + + public CompoundSortParseTests() + { + var options = new JsonApiOptions(); + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add() + .Build(); + + var request = new JsonApiRequest + { + PrimaryResourceType = resourceGraph.GetResourceType(), + IsCollection = true + }; + + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new SortParser(); + _reader = new SortQueryStringParameterReader(scopeParser, valueParser, request, resourceGraph); + } + + [Theory] + [InlineData("sort", "name")] + [InlineData("sort", "details.title")] + [InlineData("sort", "-details.nested.description")] + [InlineData("sort", "count(items)")] + [InlineData("sort", "-count(details.numbers)")] + [InlineData("sort", "count(tags),details.title")] + public void Reader_Read_Compound_Sort_Succeeds(string parameterName, string parameterValue) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + SortExpression value = (SortExpression)constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } + + [Theory] + [InlineData("sort", "details^", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] + [InlineData("sort", "^tags", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on resource type 'compoundModels' expected.")] + [InlineData("sort", "^items", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + + "To-one relationship or attribute or compound attribute on resource type 'compoundModels' expected.")] + [InlineData("sort", "count(details^)", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + + "Relationship or compound attribute or primitive collection or compound collection on compound type 'CompoundRoot' expected.")] + public void Reader_Read_Compound_Sort_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [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(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/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index 0fb39c44d6..4db9440ce4 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -97,8 +97,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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 attributes, followed by an attribute. " + + "To-one relationship or attribute or compound 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,11 +107,11 @@ 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + + "Relationship or compound attribute or primitive collection or compound collection 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + + "Relationship or compound attribute or primitive collection or compound collection 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.")] From f5f2ba56d4be5a85d383d544041c40048af0d3af Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:36:01 +0200 Subject: [PATCH 14/63] SparseFieldSetParser: Parse hierarchical attribute chains Co-authored-by: Cursor --- .../Expressions/SparseFieldSetExpression.cs | 26 +++- .../Queries/Parsing/SparseFieldSetParser.cs | 7 +- .../FieldChains/BuiltInPatterns.cs | 1 + .../QueryStrings/FieldChains/MatchState.cs | 2 +- .../CompoundSparseFieldSetParseTests.cs | 143 ++++++++++++++++++ .../SparseFieldSetParseTests.cs | 3 +- 6 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 7a4495bd83..4e777aa60f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -15,15 +15,29 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class SparseFieldSetExpression : QueryExpression { /// - /// The set of JSON:API fields to include. Chain format: a single field. + /// The set of JSON:API fields to include. /// public IImmutableSet Fields { get; } + /// + /// The set of JSON:API field chains to include. + /// + public IImmutableSet FieldChains { get; } + public SparseFieldSetExpression(IImmutableSet fields) { ArgumentGuard.NotNullNorEmpty(fields); Fields = fields; + FieldChains = fields.Select(field => new ResourceFieldChainExpression(ImmutableArray.Create(field))).ToImmutableHashSet(); + } + + public SparseFieldSetExpression(IImmutableSet fieldChains) + { + ArgumentGuard.NotNullNorEmpty(fieldChains); + + FieldChains = fieldChains; + Fields = fieldChains.Select(chain => chain.Fields[0]).ToImmutableHashSet(); } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -33,12 +47,12 @@ public override TResult Accept(QueryExpressionVisitor field.ToString()).Order()); + return string.Join(',', FieldChains.Select(chain => chain.ToString()).Order()); } public override string ToFullString() { - return string.Join(',', Fields.Select(field => $"{field.ToFullString()}").Order()); + return string.Join(',', FieldChains.Select(chain => $"{chain.ToFullString()}").Order()); } public override bool Equals(object? obj) @@ -55,16 +69,16 @@ public override bool Equals(object? obj) var other = (SparseFieldSetExpression)obj; - return Fields.SetEquals(other.Fields); + return FieldChains.SetEquals(other.FieldChains); } public override int GetHashCode() { var hashCode = new HashCode(); - foreach (ResourceFieldAttribute field in Fields) + foreach (ResourceFieldChainExpression chain in FieldChains) { - hashCode.Add(field); + hashCode.Add(chain); } return hashCode.ToHashCode(); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index fb49e9c1dd..c0aba82578 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -29,7 +29,7 @@ public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser { ArgumentNullException.ThrowIfNull(resourceType); - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); + ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); while (TokenStack.Count > 0) { @@ -39,10 +39,9 @@ public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser } ResourceFieldChainExpression nextChain = - ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); + ParseFieldChain(BuiltInPatterns.SparseFieldSetChain, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); - ResourceFieldAttribute nextField = nextChain.Fields.Single(); - fieldSetBuilder.Add(nextField); + fieldSetBuilder.Add(nextChain); } return fieldSetBuilder.Count > 0 ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 05105b5811..5c936bbf76 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -14,6 +14,7 @@ public static class BuiltInPatterns public static FieldChainPattern ToOneOrCompoundChainEndingInAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); public static FieldChainPattern ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound { get; } = FieldChainPattern.Parse("[OC]*[AOC]"); public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MPQ]"); + public static FieldChainPattern SparseFieldSetChain { get; } = FieldChainPattern.Parse("[CQ]*F"); [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"); diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index 758230f641..fcc936f474 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -99,7 +99,7 @@ public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue return relationship.RightType; } - if (matchedValue is AttrAttribute { IsCompound: true, IsCollection: false } attr) + if (matchedValue is AttrAttribute { IsCompound: true } attr) { return attr.CompoundType; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs new file mode 100644 index 0000000000..7cc706f646 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; + +public sealed class CompoundSparseFieldSetParseTests +{ + private readonly SparseFieldSetQueryStringParameterReader _reader; + + public CompoundSparseFieldSetParseTests() + { + var options = new JsonApiOptions(); + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add() + .Build(); + + var request = new JsonApiRequest + { + PrimaryResourceType = resourceGraph.GetResourceType(), + IsCollection = true + }; + + var typeParser = new SparseFieldTypeParser(resourceGraph); + var valueParser = new SparseFieldSetParser(); + _reader = new SparseFieldSetQueryStringParameterReader(typeParser, valueParser, request, resourceGraph); + } + + [Theory] + [InlineData("fields[compoundModels]", "name", "compoundModels(name)")] + [InlineData("fields[compoundModels]", "name,details.title", "compoundModels(details.title,name)")] + [InlineData("fields[compoundModels]", "details.nested.description", "compoundModels(details.nested.description)")] + [InlineData("fields[compoundModels]", "details.items.label", "compoundModels(details.items.label)")] + [InlineData("fields[compoundModels]", "details.numbers", "compoundModels(details.numbers)")] + [InlineData("fields[compoundModels]", "tags", "compoundModels(tags)")] + [InlineData("fields[compoundModels]", "details", "compoundModels(details)")] + [InlineData("fields[compoundModels]", "items", "compoundModels(items)")] + [InlineData("fields[compoundModels]", "parent", "compoundModels(parent)")] + public void Reader_Read_Compound_SparseFieldSet_Succeeds(string parameterName, string parameterValue, string expectedValue) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(expectedValue); + } + + [Theory] + [InlineData("fields[compoundModels]", "details.^unknown", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] + [InlineData("fields[compoundModels]", "parent.^name", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes or compound collections, followed by a field. " + + "End of field chain expected.")] + [InlineData("fields[compoundModels]", "tags.^something", + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes or compound collections, followed by a field. " + + "End of field chain expected.")] + public void Reader_Read_Compound_SparseFieldSet_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified fieldset is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class CompoundModel : Identifiable + { + [Attr] + public string? Name { get; set; } + + [HasOne] + public CompoundModel? Parent { 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(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/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index 19991bacae..48f9004794 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -90,7 +90,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 or compound collections, followed by a field. " + + "End of field chain expected.")] public void Reader_Read_ParameterValue_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange From 39d7590b4730d0b40105629c39d1d48583aaeb10 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:41:37 +0200 Subject: [PATCH 15/63] WhereClauseBuilder: Collection Any(), Count, and ElementValueExpression translation Co-authored-by: Cursor --- .../QueryableBuilding/IncludeClauseBuilder.cs | 2 +- .../QueryClauseBuilderContext.cs | 21 +- .../QueryableBuilding/SelectClauseBuilder.cs | 2 +- .../QueryableBuilding/WhereClauseBuilder.cs | 18 +- .../UnitTests/Queries/QueryExpressionTests.cs | 9 + .../QueryableBuilding/ClauseBuilderTests.cs | 243 ++++++++++++++++++ 6 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs 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..6187a8579a 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) @@ -91,6 +95,13 @@ public QueryClauseBuilderContext WithSource(Expression source) return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); } + public QueryClauseBuilderContext WithExtensionType(Type extensionType) + { + ArgumentNullException.ThrowIfNull(extensionType); + + return new QueryClauseBuilderContext(Source, ResourceType, extensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); + } + public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) { ArgumentNullException.ThrowIfNull(lambdaScope); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 1be86e0bf5..53dcc2aa1f 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); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index 0000456cc5..b08c988c7d 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -50,12 +50,14 @@ 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, - lambdaScope, context.QueryableBuilder, context.State); + var nestedContext = new QueryClauseBuilderContext(property, nestedResourceType, typeof(Enumerable), context.EntityModel, + context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); predicate = GetPredicateLambda(expression.Filter, nestedContext); } @@ -216,6 +218,11 @@ private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderCont return typeof(int); } + if (expression is ElementValueExpression elementValue) + { + return elementValue.ElementType; + } + if (expression is ResourceFieldChainExpression chain) { Expression child = Visit(chain, context); @@ -246,4 +253,9 @@ public override Expression VisitLiteralConstant(LiteralConstantExpression expres { return SystemExpressionBuilder.CloseOver(expression.TypedValue); } + + public override Expression VisitElementValue(ElementValueExpression expression, QueryClauseBuilderContext context) + { + return context.LambdaScope.Accessor; + } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs index 5b0ae957af..4bfbc81db3 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.ElementValue(), + TestExpressionFactory.Instance.ElementValue() + ], [ TestExpressionFactory.Instance.Has(), TestExpressionFactory.Instance.Has() @@ -335,6 +339,11 @@ public CountExpression Count() return new CountExpression(ResourceFieldChainForChildren()); } + public ElementValueExpression ElementValue() + { + return new ElementValueExpression(typeof(string)); + } + public HasExpression Has() { return new HasExpression(ResourceFieldChainForChildren(), Comparison()); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs new file mode 100644 index 0000000000..9a849d7cf3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -0,0 +1,243 @@ +using System.Collections.Immutable; +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 TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.QueryableBuilding; + +public sealed class ClauseBuilderTests +{ + private readonly IResourceGraph _resourceGraph; + private readonly ResourceType _resourceType; + private readonly IReadOnlyModel _entityModel; + private readonly FilterParser _filterParser; + private readonly SortParser _sortParser; + + public ClauseBuilderTests() + { + var options = new JsonApiOptions(); + _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add() + .Build(); + + _resourceType = _resourceGraph.GetResourceType(); + + var modelBuilder = new ModelBuilder(); + _entityModel = modelBuilder.Model.FinalizeModel(); + + var serviceContainer = new System.ComponentModel.Design.ServiceContainer(); + var 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)"); + } + + 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(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; } + } +} From d0036a30975c28ef40225b603cd1cf92b2d71d56 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:44:46 +0200 Subject: [PATCH 16/63] Scaffold CompoundAttributes integration test models, DbContext, and fakers Co-authored-by: Cursor --- .../CompoundAttributesDbContext.cs | 32 ++++ .../CompoundAttributesFakers.cs | 98 +++++++++++ .../CompoundAttributes/Entities.cs | 163 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs 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..25c12ef0f8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -0,0 +1,98 @@ +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompoundAttributes; + +internal sealed class CompoundAttributesFakers +{ + private readonly Lazy> _lazyAddressBookFaker; + private readonly Lazy> _lazyContactFaker; + private readonly Lazy> _lazyContactNoteFaker; + private readonly Lazy> _lazyContactRootFaker; + private readonly Lazy> _lazyContactNameFaker; + private readonly Lazy> _lazyContactCompanyFaker; + private readonly Lazy> _lazyContactAddressFaker; + private readonly Lazy> _lazyContactPhoneNumberFaker; + private readonly Lazy> _lazyContactDateFaker; + + public Faker AddressBook => _lazyAddressBookFaker.Value; + public Faker Contact => _lazyContactFaker.Value; + public Faker ContactNote => _lazyContactNoteFaker.Value; + public Faker ContactRoot => _lazyContactRootFaker.Value; + public Faker ContactName => _lazyContactNameFaker.Value; + public Faker ContactCompany => _lazyContactCompanyFaker.Value; + public Faker ContactAddress => _lazyContactAddressFaker.Value; + public Faker ContactPhoneNumber => _lazyContactPhoneNumberFaker.Value; + public Faker ContactDate => _lazyContactDateFaker.Value; + + public CompoundAttributesFakers() + { + _lazyContactNameFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(name => name.FirstName, faker => faker.Name.FirstName()) + .RuleFor(name => name.LastName, faker => faker.Name.LastName()) + .RuleFor(name => name.DisplayName, (faker, name) => $"{name.FirstName} {name.LastName}")); + + _lazyContactCompanyFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(company => company.Name, faker => faker.Company.CompanyName()) + .RuleFor(company => company.Department, faker => faker.Commerce.Department())); + + _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.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" } + ])); + + _lazyContactNoteFaker = new Lazy>(() => new Faker() + .MakeDeterministic() + .RuleFor(note => note.Text, faker => faker.Lorem.Sentence()) + .RuleFor(note => note.Priority, faker => faker.Random.Number(1, 5))); + + _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.SyncUrls, faker => + [ + faker.Internet.Url(), + faker.Internet.Url() + ])); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs new file mode 100644 index 0000000000..ae140465ed --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs @@ -0,0 +1,163 @@ +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; } = []; + + [Attr] + public List? SyncUrls { 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 required ContactRoot Content { get; set; } + + [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 required ContactName Name { get; set; } + + [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; } + + [Attr(IsCompound = true)] + public List> EmailAddresses { get; set; } = []; + + [Attr(IsCompound = true)] + public List> Relations { get; set; } = []; +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactName +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public required string LastName { get; set; } + + [Attr] + public required string DisplayName { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactCompany +{ + [Attr] + public required string Name { get; set; } + + [Attr] + public string? Department { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactAddress +{ + [Attr] + public required string IsoCountryCode { get; set; } + + [Attr] + public required string City { get; set; } + + [Attr] + public string? Line1 { get; set; } + + [Attr] + public string? PostalCode { get; set; } + + [Attr] + public string? Label { get; set; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ContactPhoneNumber +{ + [Attr] + public string? CountryPrefix { get; set; } + + [Attr] + public required string Number { get; set; } + + [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 required T Value { get; set; } + + [Attr] + public string? Label { get; set; } +} From bbdff417ae0a5bf3079a264bf2a54edde62c5af5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:49:41 +0200 Subject: [PATCH 17/63] Add CompoundAttributesReadTests covering GET, filters, has with '_', count, and sorts Co-authored-by: Cursor --- .../CompoundAttributesReadTests.cs | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs new file mode 100644 index 0000000000..3171506f48 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -0,0 +1,427 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +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 content = (ContactRoot)contentValue!; + content.Name.LastName.Should().Be(contact.Content.Name.LastName); + content.Name.DisplayName.Should().Be(contact.Content.Name.DisplayName); + + if (contact.Content.Company != null) + { + content.Company.Should().NotBeNull(); + content.Company!.Name.Should().Be(contact.Content.Company.Name); + } + + content.PhoneNumbers.Should().HaveCount(contact.Content.PhoneNumbers.Count); + + // Verify JSON representation + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using var jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement contentElement = jsonDocument.RootElement + .GetProperty("data") + .GetProperty("attributes") + .GetProperty("content"); + + 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_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].SyncUrls = ["https://example.com/sync", "https://backup.org"]; + addressBooks[1].SyncUrls = ["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(syncUrls,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].SyncUrls = ["https://contoso.com/sync"]; + addressBooks[1].SyncUrls = ["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(syncUrls,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].SyncUrls = ["url1", "url2", "url3"]; + addressBooks[1].SyncUrls = ["url1"]; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddressBooks.AddRange(addressBooks); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/addressBooks?filter=greaterThan(count(syncUrls),'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); + } +} From 7a8dfb4954d7ea3cba917e4e3213caba50314e72 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:53:30 +0200 Subject: [PATCH 18/63] ResourceObjectAdapter: Recursive deserialization of compound objects & collections Co-authored-by: Cursor --- .../Request/Adapters/ResourceObjectAdapter.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 2df888846e..c22818ccee 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,6 +68,30 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); AssertNotReadOnly(attr, resourceType, state); + 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.IsCompound || 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); } From 3feaf15032d984e220b570b40418256f1ae63b42 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:53:34 +0200 Subject: [PATCH 19/63] ResourceChangeTracker: Deep structural equality for compound attributes Co-authored-by: Cursor --- .../Resources/ResourceChangeTracker.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index c17ce60b45..ecbc3e03ad 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; @@ -49,13 +50,19 @@ public void SetFinallyStoredAttributeValues(TResource resource) _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private static Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { var result = new Dictionary(); 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); + } } From 58f5ce2d844389107f2a85d015ee48fc767f7026 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:53:38 +0200 Subject: [PATCH 20/63] Add CompoundAttributesWriteTests covering POST and PATCH operations Co-authored-by: Cursor --- .../CompoundAttributesWriteTests.cs | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs new file mode 100644 index 0000000000..3c2d80f3cb --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +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", + displayName = "John 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", + syncUrls = 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.SyncUrls.Should().NotBeNull(); + addressBookInDatabase.SyncUrls.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", + displayName = "Jane 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.SyncUrls = ["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 + { + syncUrls = 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.SyncUrls.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, + displayName = existingContact.Content.Name.DisplayName + }, + 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(); + }); + } +} From 14d4e214697fe176dceaf856a660a333fbcb3379 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:10:19 +0200 Subject: [PATCH 21/63] Hierarchical FieldSelection, SparseFieldSetCache, and SelectClauseBuilder Co-authored-by: Cursor --- .../Queries/FieldSelection.cs | 19 ++++ .../Queries/FieldSelectors.cs | 38 ++++++++ .../Queries/ISparseFieldSetCache.cs | 11 +++ .../Queries/QueryLayerComposer.cs | 39 +++++++- .../QueryableBuilding/SelectClauseBuilder.cs | 45 ++++++++- .../Queries/SparseFieldSetCache.cs | 96 ++++++++++--------- 6 files changed, 198 insertions(+), 50 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs index 246b1ce8e1..4d96cf3c72 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -72,6 +72,10 @@ private void WriterSelectors(IndentingStringWriter writer, bool toFullString, Re if (nextLayer == null) { writer.WriteLine(toFullString ? field.ToFullString() : field.ToString()); + if (field is AttrAttribute attr && GetOrCreateSelectors(type).GetChildSelectors(attr) is { } childSelectors) + { + WriteChildSelectors(writer, toFullString, childSelectors); + } } else { @@ -81,4 +85,19 @@ private void WriterSelectors(IndentingStringWriter writer, bool toFullString, Re } } } + + private static void WriteChildSelectors(IndentingStringWriter writer, bool toFullString, FieldSelectors childSelectors) + { + using (writer.Indent()) + { + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in childSelectors) + { + 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..7f7ad7df87 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,35 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class FieldSelectors : Dictionary { + private Dictionary? _childSelectors; + public bool IsEmpty => Count == 0; + public bool HasChildSelectors => _childSelectors is { Count: > 0 }; + + public IReadOnlyDictionary ChildSelectors => + _childSelectors ?? (IReadOnlyDictionary)ImmutableDictionary.Empty; + + public FieldSelectors? GetChildSelectors(AttrAttribute compoundAttribute) + { + ArgumentNullException.ThrowIfNull(compoundAttribute); + return _childSelectors?.GetValueOrDefault(compoundAttribute); + } + + 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 ContainsReadOnlyAttribute { get @@ -42,6 +70,16 @@ public void IncludeAttribute(AttrAttribute attribute) this[attribute] = null; } + public void IncludeCompoundAttribute(AttrAttribute compoundAttribute, FieldSelectors childSelectors) + { + ArgumentNullException.ThrowIfNull(compoundAttribute); + ArgumentNullException.ThrowIfNull(childSelectors); + + this[compoundAttribute] = null; + _childSelectors ??= new Dictionary(); + _childSelectors[compoundAttribute] = childSelectors; + } + public void IncludeAttributes(IEnumerable attributes) { ArgumentNullException.ThrowIfNull(attributes); diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs index 22046d3bca..9eb50937a0 100644 --- a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -32,6 +33,16 @@ public interface ISparseFieldSetCache /// IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType); + /// + /// Gets the sparse fieldset expression to retrieve from the underlying data store, or null to retrieve all fields. + /// + SparseFieldSetExpression? GetSparseFieldSetExpressionForQuery(ResourceType resourceType); + + /// + /// Gets the evaluated sparse fieldset expression to serialize into the response body. + /// + SparseFieldSetExpression GetSparseFieldSetExpressionForSerializer(ResourceType resourceType); + /// /// Resets the cached results from resource definition callbacks. /// diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index f058862bcd..4251ccbf4e 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -605,17 +605,48 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); + SparseFieldSetExpression? fieldSetExpression = _sparseFieldSetCache.GetSparseFieldSetExpressionForQuery(nextType); - if (fieldSet.Count == 0) + if (fieldSetExpression == null || fieldSetExpression.FieldChains.Count == 0) { continue; } - HashSet attributeSet = fieldSet.OfType().ToHashSet(); + // In EF Core 10, MemberInit projection on entity with ComplexProperty fails in ShaperProcessingExpressionVisitor + // with InvalidCastException: Unable to cast RuntimeComplexType to IEntityType. + // If the resource type has any complex properties, do not project MemberInit; let full entity be fetched and pruned in serializer. + if (nextType.Attributes.Any(attr => attr.IsCompound)) + { + continue; + } FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); - selectors.IncludeAttributes(attributeSet); + + foreach (ResourceFieldChainExpression chain in fieldSetExpression.FieldChains) + { + if (chain.Fields.Count == 1) + { + if (chain.Fields[0] is AttrAttribute singleAttr) + { + selectors.IncludeAttribute(singleAttr); + } + } + else + { + FieldSelectors currentSelectors = selectors; + for (int i = 0; i < chain.Fields.Count - 1; i++) + { + var parentAttr = (AttrAttribute)chain.Fields[i]; + currentSelectors.IncludeAttribute(parentAttr); + currentSelectors = currentSelectors.GetOrCreateChildSelectors(parentAttr); + } + + if (chain.Fields[^1] is AttrAttribute leafAttr) + { + currentSelectors.IncludeAttribute(leafAttr); + } + } + } AttrAttribute idAttribute = GetIdAttribute(nextType); selectors.IncludeAttribute(idAttribute); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 53dcc2aa1f..58df971197 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -166,7 +166,8 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary(); + + 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 }) + { + 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, @@ -277,13 +314,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/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs index f2c5593ab1..809636593d 100644 --- a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -13,8 +13,9 @@ public sealed class SparseFieldSetCache : ISparseFieldSetCache private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy>> _lazySourceTable; - private readonly Dictionary> _visitedTable = []; + private readonly Lazy> _lazySourceTable; + private readonly Dictionary _visitedQueryTable = []; + private readonly Dictionary _visitedSerializerTable = []; public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) { @@ -22,10 +23,10 @@ public SparseFieldSetCache(IEnumerable constraintProvi ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); + _lazySourceTable = new Lazy>(() => BuildSourceTable(constraintProviders)); } - private static Dictionary> BuildSourceTable( + private static Dictionary BuildSourceTable( IEnumerable constraintProviders) { // @formatter:wrap_chained_method_calls chop_always @@ -43,50 +44,76 @@ private static Dictionary // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore - var mergedTable = new Dictionary.Builder>(); + var mergedTable = new Dictionary.Builder>(); foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) { - if (!mergedTable.TryGetValue(resourceType, out ImmutableHashSet.Builder? builder)) + if (!mergedTable.TryGetValue(resourceType, out ImmutableHashSet.Builder? builder)) { - builder = ImmutableHashSet.CreateBuilder(); + builder = ImmutableHashSet.CreateBuilder(); mergedTable[resourceType] = builder; } - AddSparseFieldsToSet(sparseFieldSet.Fields, builder); + foreach (ResourceFieldChainExpression chain in sparseFieldSet.FieldChains) + { + builder.Add(chain); + } } - return mergedTable.ToDictionary(pair => pair.Key, pair => pair.Value.ToImmutable()); + return mergedTable.ToDictionary(pair => pair.Key, pair => new SparseFieldSetExpression(pair.Value.ToImmutable())); } - private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, - ImmutableHashSet.Builder sparseFieldSetBuilder) + /// + public SparseFieldSetExpression? GetSparseFieldSetExpressionForQuery(ResourceType resourceType) { - foreach (ResourceFieldAttribute field in sparseFieldsToAdd) + ArgumentNullException.ThrowIfNull(resourceType); + + if (!_visitedQueryTable.TryGetValue(resourceType, out SparseFieldSetExpression? outputExpression)) { - sparseFieldSetBuilder.Add(field); + SparseFieldSetExpression? inputExpression = _lazySourceTable.Value.GetValueOrDefault(resourceType); + + outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + _visitedQueryTable[resourceType] = outputExpression; } + + return outputExpression; } /// - public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) + public SparseFieldSetExpression GetSparseFieldSetExpressionForSerializer(ResourceType resourceType) { ArgumentNullException.ThrowIfNull(resourceType); - if (!_visitedTable.TryGetValue(resourceType, out IImmutableSet? outputFields)) + if (!_visitedSerializerTable.TryGetValue(resourceType, out SparseFieldSetExpression? outputExpression)) { - SparseFieldSetExpression? inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out ImmutableHashSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : null; + SparseFieldSetExpression inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out SparseFieldSetExpression? inputFields) + ? inputFields + : GetCachedViewableFieldSet(resourceType); - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - outputFields = outputExpression == null ? ImmutableHashSet.Empty : outputExpression.Fields; + SparseFieldSetExpression? callbackExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - _visitedTable[resourceType] = outputFields; + outputExpression = callbackExpression == null + ? GetCachedViewableFieldSet(resourceType) + : IntersectExpressions(inputExpression, callbackExpression); + + _visitedSerializerTable[resourceType] = outputExpression; } - return outputFields; + return outputExpression; + } + + private static SparseFieldSetExpression IntersectExpressions(SparseFieldSetExpression first, SparseFieldSetExpression second) + { + IImmutableSet intersectedChains = first.FieldChains.Intersect(second.FieldChains); + return intersectedChains.Count == 0 ? first : new SparseFieldSetExpression(intersectedChains); + } + + /// + public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) + { + SparseFieldSetExpression? expression = GetSparseFieldSetExpressionForQuery(resourceType); + return expression == null ? ImmutableHashSet.Empty : expression.Fields; } /// @@ -111,25 +138,7 @@ public IImmutableSet GetIdAttributeSetForRelationshipQuery(Resour /// 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; + return GetSparseFieldSetExpressionForSerializer(resourceType).Fields; } private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) @@ -151,6 +160,7 @@ private static ImmutableHashSet GetViewableFields(Resour public void Reset() { - _visitedTable.Clear(); + _visitedQueryTable.Clear(); + _visitedSerializerTable.Clear(); } } From 7d476585ddb35869d011dddd13478b8d20be12b9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:10:31 +0200 Subject: [PATCH 22/63] ResponseModelAdapter: Prune unselected compound properties and collections Co-authored-by: Cursor --- .../Response/ResponseModelAdapter.cs | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index c4a530fdd0..e6a5bd76e1 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -224,9 +224,9 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (!isRelationship) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); + SparseFieldSetExpression fieldSetExpression = _sparseFieldSetCache.GetSparseFieldSetExpressionForSerializer(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); } @@ -236,16 +236,22 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, IImmutableSet fieldSet) + { + return ConvertAttributes(resource, resourceType, new SparseFieldSetExpression(fieldSet)); + } + + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, + SparseFieldSetExpression fieldSetExpression) { ArgumentNullException.ThrowIfNull(resource); ArgumentNullException.ThrowIfNull(resourceType); - ArgumentNullException.ThrowIfNull(fieldSet); + ArgumentNullException.ThrowIfNull(fieldSetExpression); var attrMap = new Dictionary(resourceType.Attributes.Count); foreach (AttrAttribute attr in resourceType.Attributes) { - if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable<>.Id)) + if (!fieldSetExpression.Fields.Contains(attr) || attr.Property.Name == nameof(Identifiable<>.Id)) { continue; } @@ -263,12 +269,94 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc continue; } + if (value != null && attr.IsCompound) + { + // Check if any field chains exist for this compound attribute with deeper paths + List subChains = fieldSetExpression.FieldChains + .Where(chain => chain.Fields.Count > 1 && chain.Fields[0] == attr) + .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) + .ToList(); + + if (subChains.Count > 0) + { + if (attr.IsCollection && value is System.Collections.IEnumerable collection) + { + var prunedList = new List(); + foreach (object item in collection) + { + prunedList.Add(PruneCompoundObject(item, (CompoundType)attr.CompoundType!, subChains)); + } + value = prunedList; + } + else + { + value = PruneCompoundObject(value, (CompoundType)attr.CompoundType!, subChains); + } + } + } + attrMap.Add(attr.PublicName, value); } return attrMap.Count > 0 ? attrMap : null; } + private object? PruneCompoundObject(object compoundInstance, CompoundType compoundType, IReadOnlyList fieldChains) + { + var prunedMap = new Dictionary(); + HashSet directFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); + + foreach (AttrAttribute childAttr in compoundType.Attributes) + { + if (!directFields.Contains(childAttr)) + { + 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) + { + List deeperChains = fieldChains + .Where(chain => chain.Fields.Count > 1 && chain.Fields[0] == childAttr) + .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) + .ToList(); + + if (deeperChains.Count > 0) + { + if (childAttr.IsCollection && childValue is System.Collections.IEnumerable collection) + { + var prunedList = new List(); + foreach (object item in collection) + { + prunedList.Add(PruneCompoundObject(item, (CompoundType)childAttr.CompoundType!, deeperChains)); + } + childValue = prunedList; + } + else + { + childValue = PruneCompoundObject(childValue, (CompoundType)childAttr.CompoundType!, deeperChains); + } + } + } + + prunedMap[childAttr.PublicName] = childValue; + } + + return prunedMap; + } + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, EndpointKind kind) { From e903bcc401030651759d71e43081604be5fbeeff Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:10:35 +0200 Subject: [PATCH 23/63] Add CompoundAttributesSparseFieldSetTests Co-authored-by: Cursor --- .../CompoundAttributesSparseFieldSetTests.cs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs new file mode 100644 index 0000000000..51b79caaca --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -0,0 +1,261 @@ +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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("remarks"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using var jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement contentElement = jsonDocument.RootElement + .GetProperty("data") + .GetProperty("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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("remarks"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using var jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement contentElement = jsonDocument.RootElement + .GetProperty("data") + .GetProperty("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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("remarks"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using var jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement contentElement = jsonDocument.RootElement + .GetProperty("data") + .GetProperty("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]=syncUrls"; + + // 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("syncUrls"); + } + + [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, 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"); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using var jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement favoritesElement = jsonDocument.RootElement + .GetProperty("data") + .GetProperty("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(); + } + } +} From f11a0f05ede2e2e72a72a6bb6e4d79248ca81c64 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:15:30 +0200 Subject: [PATCH 24/63] Add CompoundAttributesValidationTests and reject isType on compound types --- .../Queries/Parsing/FilterParser.cs | 6 + .../CompoundAttributesValidationTests.cs | 155 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index d8165f6982..fc61b7deb8 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -589,6 +589,12 @@ protected virtual IsTypeExpression ParseIsType() throw new QueryParseException("Function 'isType' cannot be used on primitive collection elements.", position); } + if (ContainerInScope is not ResourceType) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'isType' cannot be used on compound types.", position); + } + EatText(Keywords.IsType); EatSingleCharacterToken(TokenKind.OpenParen); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs new file mode 100644 index 0000000000..7c8c34b4f6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -0,0 +1,155 @@ +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; + + 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 attributes, followed by an 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 attributes, followed by an 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 attributes, followed by a to-one relationship or an attribute or a compound 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + } + + [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 attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + } + + [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' cannot be used on compound types"); + } +} From 3b21b6e7622ea67c14d811bf38c193967ad0e9ba Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:20:16 +0200 Subject: [PATCH 25/63] XML documentation, code style cleanup, and roadmap update --- ROADMAP.md | 2 +- .../Configuration/CompoundType.cs | 2 +- .../Resources/Annotations/AttrAttribute.cs | 2 ++ .../Configuration/ResourceGraphBuilder.cs | 7 +++++- .../ServiceCollectionExtensions.cs | 2 +- .../Expressions/ElementValueExpression.cs | 21 ++++++++++++---- .../ResourceFieldChainExpression.cs | 2 +- .../Queries/FieldSelection.cs | 4 +++- .../Queries/FieldSelectors.cs | 15 ++++++++++++ .../Queries/ISparseFieldSetCache.cs | 2 ++ .../Queries/Parsing/FilterParser.cs | 6 ++--- .../Queries/QueryLayerComposer.cs | 1 + .../QueryableBuilding/SelectClauseBuilder.cs | 5 ++-- .../QueryStrings/FieldChains/FieldTypes.cs | 4 +--- .../QueryStrings/FieldChains/MatchState.cs | 2 +- .../FieldChains/PatternMatcher.cs | 9 ++----- .../Response/ResponseModelAdapter.cs | 18 ++++++++------ .../CompoundAttributesFakers.cs | 18 +++++--------- .../CompoundAttributesReadTests.cs | 8 +++---- .../CompoundAttributesSparseFieldSetTests.cs | 8 +++---- .../CompoundAttributesWriteTests.cs | 6 ++--- .../CompoundFilterParseTests.cs | 3 +-- .../CompoundSortParseTests.cs | 3 +-- .../CompoundSparseFieldSetParseTests.cs | 1 - .../QueryableBuilding/ClauseBuilderTests.cs | 24 ++++++++++++------- .../ResourceGraph/CompoundTypeTests.cs | 2 +- .../ResourceGraphBuilderTests.cs | 13 +++++----- 27 files changed, 109 insertions(+), 81 deletions(-) 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/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index 7203578260..ed17790d18 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -23,7 +23,7 @@ public CompoundType(Type clrType, IReadOnlyCollection? attributes ArgumentNullException.ThrowIfNull(clrType); ClrType = clrType; - Attributes = attributes ?? Array.Empty(); + Attributes = attributes ?? []; foreach (AttrAttribute attribute in Attributes) { diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 026b798306..5f2735fd7d 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -1,6 +1,8 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +// ReSharper disable NonReadonlyMemberInGetHashCode + namespace JsonApiDotNetCore.Resources.Annotations; /// diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index c957e1a14f..bc16fb0624 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -430,6 +430,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu foreach (PropertyInfo property in compoundClrType.GetProperties()) { var relationship = property.GetCustomAttribute(true); + if (relationship != null) { throw new InvalidConfigurationException( @@ -437,6 +438,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu } var eagerLoad = property.GetCustomAttribute(true); + if (eagerLoad != null) { throw new InvalidConfigurationException( @@ -444,6 +446,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu } var attribute = property.GetCustomAttribute(true); + if (attribute != null) { SetPublicName(attribute, property); @@ -465,6 +468,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu } var newCompoundType = new CompoundType(compoundClrType, attributesByName.Values); + foreach (AttrAttribute attribute in newCompoundType.Attributes) { attribute.ContainingType = newCompoundType; @@ -501,9 +505,10 @@ private void AssertNoCyclesInCompoundTypes(Type currentType, HashSet visit inPath.Add(currentType); CompoundType compoundType = _compoundTypesByClrType[currentType]; + foreach (AttrAttribute attribute in compoundType.Attributes) { - if (attribute.IsCompound && attribute.CompoundType != null) + if (attribute is { IsCompound: true, CompoundType: not null }) { AssertNoCyclesInCompoundTypes(attribute.CompoundType.ClrType, visited, inPath); } 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/Queries/Expressions/ElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs index f63a3778c2..de5ceb9814 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs @@ -24,12 +24,23 @@ public override TResult Accept(QueryExpressionVisitor "_"; + public override string ToString() + { + return "_"; + } - public override string ToFullString() => "_"; + public override string ToFullString() + { + return "_"; + } - public override bool Equals(object? obj) => - ReferenceEquals(this, obj) || (obj is ElementValueExpression other && ElementType == other.ElementType); + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || (obj is ElementValueExpression other && ElementType == other.ElementType); + } - public override int GetHashCode() => ElementType.GetHashCode(); + public override int GetHashCode() + { + return ElementType.GetHashCode(); + } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index a34872dc6b..78705669d2 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -20,7 +20,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class ResourceFieldChainExpression : IdentifierExpression { /// - /// A list of one or more JSON:API fields. Use to convert from text. + /// A list of one or more JSON:API fields. Use to convert from text. /// public IImmutableList Fields { get; } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs index 4d96cf3c72..d3aabacf30 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -72,6 +72,7 @@ private void WriterSelectors(IndentingStringWriter writer, bool toFullString, Re if (nextLayer == null) { writer.WriteLine(toFullString ? field.ToFullString() : field.ToString()); + if (field is AttrAttribute attr && GetOrCreateSelectors(type).GetChildSelectors(attr) is { } childSelectors) { WriteChildSelectors(writer, toFullString, childSelectors); @@ -90,9 +91,10 @@ private static void WriteChildSelectors(IndentingStringWriter writer, bool toFul { using (writer.Indent()) { - foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in childSelectors) + 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 7f7ad7df87..2fa8f63af6 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -15,17 +15,29 @@ public sealed class FieldSelectors : Dictionary 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; + /// + /// 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); @@ -70,6 +82,9 @@ public void IncludeAttribute(AttrAttribute attribute) this[attribute] = null; } + /// + /// Includes the compound attribute along with its nested child field selectors. + /// public void IncludeCompoundAttribute(AttrAttribute compoundAttribute, FieldSelectors childSelectors) { ArgumentNullException.ThrowIfNull(compoundAttribute); diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs index 9eb50937a0..3220bbf370 100644 --- a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -15,6 +16,7 @@ namespace JsonApiDotNetCore.Queries; /// 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. /// +[PublicAPI] public interface ISparseFieldSetCache { /// diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index fc61b7deb8..372b251590 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -331,8 +331,8 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); string errorMessage = PrimitiveElementInScope != null - ? (allowNull ? "Function, value between quotes, null or '_' expected." : "Function, value between quotes or '_' expected.") - : (allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."); + ? allowNull ? "Function, value between quotes, null or '_' expected." : "Function, value between quotes or '_' expected." + : allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; if (TokenStack.TryPeek(out Token? nextToken)) { @@ -517,8 +517,6 @@ protected virtual AnyExpression ParseAny() throw new QueryParseException("Function or '_' expected.", position); } - int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index 4251ccbf4e..4c61c406b9 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -634,6 +634,7 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection(); @@ -235,6 +235,7 @@ private Expression CreateCompoundMemberInit(FieldSelectors childSelectors, Membe FieldSelectors? grandchildSelectors = childSelectors.GetChildSelectors(childAttr); Expression childRightHandSide; + if (grandchildSelectors is { IsEmpty: false }) { childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context); diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs index e2e0ab1fb9..3f6c503641 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -12,7 +12,5 @@ internal enum FieldTypes CompoundCollection = 1 << 5, Relationship = ToOneRelationship | ToManyRelationship, - Field = ScalarAttribute | Relationship | CompoundAttribute | PrimitiveCollection | CompoundCollection, - - Attribute = ScalarAttribute + Field = ScalarAttribute | Relationship | CompoundAttribute | PrimitiveCollection | CompoundCollection } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index fcc936f474..c9f137c291 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -28,7 +28,7 @@ internal sealed class MatchState /// /// The resource type to find the next field on. /// - public ResourceType? ResourceType => Container as ResourceType; + private ResourceType? ResourceType => Container as ResourceType; /// /// The fields matched against this pattern segment. diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 50cc0b89ec..32084b58c5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -23,11 +23,6 @@ public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions o _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); } - public PatternMatchResult Match(string fieldChain, ResourceType resourceType) - { - return Match(fieldChain, (IAttributeContainer)resourceType); - } - public PatternMatchResult Match(string fieldChain, IAttributeContainer container) { ArgumentNullException.ThrowIfNull(fieldChain); @@ -220,9 +215,9 @@ private HashSet LookupFields(IAttributeContainer? contai } } } - else if (container != null) + else { - AttrAttribute? attribute = container.FindAttributeByPublicName(publicName); + AttrAttribute? attribute = container?.FindAttributeByPublicName(publicName); if (attribute != null) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index e6a5bd76e1..288b69fbcc 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -273,7 +273,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc { // Check if any field chains exist for this compound attribute with deeper paths List subChains = fieldSetExpression.FieldChains - .Where(chain => chain.Fields.Count > 1 && chain.Fields[0] == attr) + .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) .ToList(); @@ -282,15 +282,17 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (attr.IsCollection && value is System.Collections.IEnumerable collection) { var prunedList = new List(); + foreach (object item in collection) { - prunedList.Add(PruneCompoundObject(item, (CompoundType)attr.CompoundType!, subChains)); + prunedList.Add(PruneCompoundObject(item, attr.CompoundType!, subChains)); } + value = prunedList; } else { - value = PruneCompoundObject(value, (CompoundType)attr.CompoundType!, subChains); + value = PruneCompoundObject(value, attr.CompoundType!, subChains); } } } @@ -301,7 +303,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return attrMap.Count > 0 ? attrMap : null; } - private object? PruneCompoundObject(object compoundInstance, CompoundType compoundType, IReadOnlyList fieldChains) + private Dictionary PruneCompoundObject(object compoundInstance, CompoundType compoundType, IReadOnlyList fieldChains) { var prunedMap = new Dictionary(); HashSet directFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); @@ -329,7 +331,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (childValue != null && childAttr.IsCompound) { List deeperChains = fieldChains - .Where(chain => chain.Fields.Count > 1 && chain.Fields[0] == childAttr) + .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(childAttr)) .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) .ToList(); @@ -338,15 +340,17 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (childAttr.IsCollection && childValue is System.Collections.IEnumerable collection) { var prunedList = new List(); + foreach (object item in collection) { - prunedList.Add(PruneCompoundObject(item, (CompoundType)childAttr.CompoundType!, deeperChains)); + prunedList.Add(PruneCompoundObject(item, childAttr.CompoundType!, deeperChains)); } + childValue = prunedList; } else { - childValue = PruneCompoundObject(childValue, (CompoundType)childAttr.CompoundType!, deeperChains); + childValue = PruneCompoundObject(childValue, childAttr.CompoundType!, deeperChains); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index 25c12ef0f8..9b960a36bb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -7,7 +7,6 @@ internal sealed class CompoundAttributesFakers { private readonly Lazy> _lazyAddressBookFaker; private readonly Lazy> _lazyContactFaker; - private readonly Lazy> _lazyContactNoteFaker; private readonly Lazy> _lazyContactRootFaker; private readonly Lazy> _lazyContactNameFaker; private readonly Lazy> _lazyContactCompanyFaker; @@ -15,15 +14,15 @@ internal sealed class CompoundAttributesFakers private readonly Lazy> _lazyContactPhoneNumberFaker; private readonly Lazy> _lazyContactDateFaker; + private Faker ContactName => _lazyContactNameFaker.Value; + private Faker ContactAddress => _lazyContactAddressFaker.Value; + private Faker ContactPhoneNumber => _lazyContactPhoneNumberFaker.Value; + private Faker ContactDate => _lazyContactDateFaker.Value; + public Faker AddressBook => _lazyAddressBookFaker.Value; public Faker Contact => _lazyContactFaker.Value; - public Faker ContactNote => _lazyContactNoteFaker.Value; public Faker ContactRoot => _lazyContactRootFaker.Value; - public Faker ContactName => _lazyContactNameFaker.Value; public Faker ContactCompany => _lazyContactCompanyFaker.Value; - public Faker ContactAddress => _lazyContactAddressFaker.Value; - public Faker ContactPhoneNumber => _lazyContactPhoneNumberFaker.Value; - public Faker ContactDate => _lazyContactDateFaker.Value; public CompoundAttributesFakers() { @@ -31,7 +30,7 @@ public CompoundAttributesFakers() .MakeDeterministic() .RuleFor(name => name.FirstName, faker => faker.Name.FirstName()) .RuleFor(name => name.LastName, faker => faker.Name.LastName()) - .RuleFor(name => name.DisplayName, (faker, name) => $"{name.FirstName} {name.LastName}")); + .RuleFor(name => name.DisplayName, (_, name) => $"{name.FirstName} {name.LastName}")); _lazyContactCompanyFaker = new Lazy>(() => new Faker() .MakeDeterministic() @@ -74,11 +73,6 @@ public CompoundAttributesFakers() new LabeledValue { Value = faker.Name.FullName(), Label = "friend" } ])); - _lazyContactNoteFaker = new Lazy>(() => new Faker() - .MakeDeterministic() - .RuleFor(note => note.Text, faker => faker.Lorem.Sentence()) - .RuleFor(note => note.Priority, faker => faker.Random.Number(1, 5))); - _lazyContactFaker = new Lazy>(() => new Faker() .MakeDeterministic() .RuleFor(contact => contact.Content, _ => ContactRoot.Generate()) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index 3171506f48..d57ecdad41 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -1,9 +1,7 @@ using System.Net; using System.Text.Json; using FluentAssertions; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -53,7 +51,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => object? contentValue = responseDocument.Data.SingleValue.Attributes!["content"]; contentValue.Should().BeOfType(); - var content = (ContactRoot)contentValue!; + var content = (ContactRoot)contentValue; content.Name.LastName.Should().Be(contact.Content.Name.LastName); content.Name.DisplayName.Should().Be(contact.Content.Name.DisplayName); @@ -67,7 +65,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Verify JSON representation string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using var jsonDocument = JsonDocument.Parse(responseRaw); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); JsonElement contentElement = jsonDocument.RootElement .GetProperty("data") .GetProperty("attributes") @@ -101,7 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // 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]); + responseDocument.Data.ManyValue.Select(resource => resource.Id).Should().BeEquivalentTo(contacts[0].StringId, contacts[2].StringId); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs index 51b79caaca..2e67aeb71f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -49,7 +49,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using var jsonDocument = JsonDocument.Parse(responseRaw); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); JsonElement contentElement = jsonDocument.RootElement .GetProperty("data") .GetProperty("attributes") @@ -90,7 +90,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using var jsonDocument = JsonDocument.Parse(responseRaw); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); JsonElement contentElement = jsonDocument.RootElement .GetProperty("data") .GetProperty("attributes") @@ -126,7 +126,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using var jsonDocument = JsonDocument.Parse(responseRaw); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); JsonElement contentElement = jsonDocument.RootElement .GetProperty("data") .GetProperty("attributes") @@ -242,7 +242,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("favorites"); string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using var jsonDocument = JsonDocument.Parse(responseRaw); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); JsonElement favoritesElement = jsonDocument.RootElement .GetProperty("data") .GetProperty("attributes") diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs index 3c2d80f3cb..e6707e1d9d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -1,8 +1,6 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; @@ -125,7 +123,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => addressBookInDatabase.Name.Should().Be("Work Address Book"); addressBookInDatabase.SyncUrls.Should().NotBeNull(); - addressBookInDatabase.SyncUrls.Should().BeEquivalentTo(["https://example.com/sync", "https://backup.example.com"]); + addressBookInDatabase.SyncUrls.Should().BeEquivalentTo("https://example.com/sync", "https://backup.example.com"); }); } @@ -275,7 +273,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(existingAddressBook.Id); - addressBookInDatabase.SyncUrls.Should().BeEquivalentTo(["https://new1.com"]); + addressBookInDatabase.SyncUrls.Should().BeEquivalentTo("https://new1.com"); }); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index e59d6293bf..0152744d5a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -116,7 +115,7 @@ public void ElementValueExpression_Properties_And_Equality() var expr2 = new ElementValueExpression(typeof(string)); var expr3 = new ElementValueExpression(typeof(int)); - expr1.ElementType.Should().Be(typeof(string)); + expr1.ElementType.Should().Be(); expr1.ToString().Should().Be("_"); expr1.ToFullString().Should().Be("_"); expr1.Should().Be(expr2); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs index 358c4947d3..9f2d7b8d65 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs @@ -2,7 +2,6 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; @@ -56,7 +55,7 @@ public void Reader_Read_Compound_Sort_Succeeds(string parameterName, string para IReadOnlyCollection constraints = _reader.GetConstraints(); // Assert - SortExpression value = (SortExpression)constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + var value = (SortExpression)constraints.Select(expressionInScope => expressionInScope.Expression).Single(); value.ToString().Should().Be(parameterValue); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs index 7cc706f646..1b91d6091d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs @@ -2,7 +2,6 @@ using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs index 9a849d7cf3..9d19e4fded 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Linq.Expressions; using FluentAssertions; using JetBrains.Annotations; @@ -12,37 +11,41 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryableBuilding; -public sealed class ClauseBuilderTests +public sealed class ClauseBuilderTests : IDisposable { - private readonly IResourceGraph _resourceGraph; private readonly ResourceType _resourceType; private readonly IReadOnlyModel _entityModel; + private readonly System.ComponentModel.Design.ServiceContainer _serviceContainer; private readonly FilterParser _filterParser; private readonly SortParser _sortParser; public ClauseBuilderTests() { var options = new JsonApiOptions(); - _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) .Add() .Build(); - _resourceType = _resourceGraph.GetResourceType(); + _resourceType = resourceGraph.GetResourceType(); var modelBuilder = new ModelBuilder(); _entityModel = modelBuilder.Model.FinalizeModel(); - var serviceContainer = new System.ComponentModel.Design.ServiceContainer(); - var resourceFactory = new ResourceFactory(serviceContainer); + _serviceContainer = new System.ComponentModel.Design.ServiceContainer(); + var resourceFactory = new ResourceFactory(_serviceContainer); _filterParser = new FilterParser(resourceFactory); _sortParser = new SortParser(); } + public void Dispose() + { + _serviceContainer.Dispose(); + } + [Fact] public void Builds_where_clause_for_nested_compound_attribute() { @@ -192,7 +195,10 @@ public void Builds_order_by_clause_for_nested_scalar() private sealed class DummyQueryableBuilder : IQueryableBuilder { - public Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) => throw new NotImplementedException(); + public Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) + { + throw new NotImplementedException(); + } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs index eaa22e5a92..e216df755f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -32,7 +32,7 @@ public void Can_lookup_attributes_in_compound_type() var compoundType = new CompoundType(typeof(TestAddress), [streetAttribute]); // Act & Assert - compoundType.ClrType.Should().Be(typeof(TestAddress)); + compoundType.ClrType.Should().Be(); compoundType.Attributes.Should().ContainSingle().Which.Should().Be(streetAttribute); compoundType.ToString().Should().Be(nameof(TestAddress)); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 13aadc4b2e..ea13cc23c9 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -582,7 +582,7 @@ public void Discovers_compound_types_recursively() contentAttr.ContainingType.Should().Be(resourceType); CompoundType rootCompoundType = contentAttr.CompoundType!; - rootCompoundType.ClrType.Should().Be(typeof(TestRootCompound)); + rootCompoundType.ClrType.Should().Be(); rootCompoundType.Attributes.Should().HaveCount(2); AttrAttribute nameAttr = rootCompoundType.GetAttributeByPropertyName(nameof(TestRootCompound.Name)); @@ -591,7 +591,7 @@ public void Discovers_compound_types_recursively() nameAttr.ContainingType.Should().Be(rootCompoundType); CompoundType nameCompoundType = nameAttr.CompoundType!; - nameCompoundType.ClrType.Should().Be(typeof(TestChildCompound)); + nameCompoundType.ClrType.Should().Be(); AttrAttribute firstAttr = nameCompoundType.GetAttributeByPropertyName(nameof(TestChildCompound.FirstName)); firstAttr.IsCompound.Should().BeFalse(); firstAttr.CompoundType.Should().BeNull(); @@ -601,7 +601,7 @@ public void Discovers_compound_types_recursively() phonesAttr.IsCompound.Should().BeTrue(); phonesAttr.IsCollection.Should().BeTrue(); phonesAttr.CompoundType.Should().NotBeNull(); - phonesAttr.CompoundType!.ClrType.Should().Be(typeof(TestPhoneCompound)); + phonesAttr.CompoundType!.ClrType.Should().Be(); resourceGraph.GetCompoundTypes().Should().HaveCount(3); resourceGraph.GetCompoundType(typeof(TestRootCompound)).Should().Be(rootCompoundType); @@ -672,7 +672,7 @@ public void Cannot_annotate_compound_type_with_ResourceAttribute() // Assert action.Should().ThrowExactly().WithMessage( - $"Type '{typeof(CompoundWithResourceAttribute)}' is a compound type and cannot be annotated with [Resource]."); + $"Type '{typeof(CompoundWithResourceAnnotation)}' is a compound type and cannot be annotated with [Resource]."); } [Fact] @@ -796,8 +796,9 @@ public sealed class ResourceWithRegisteredResourceAsCompound : Identifiable { [Attr(IsCompound = true)] - public CompoundWithResourceAttribute? Child { get; set; } + public CompoundWithResourceAnnotation? Child { get; set; } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] From dca7f65b7ef2c7205cc165abb084c0afd36a2ffe Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:24:24 +0200 Subject: [PATCH 26/63] Reformat code with cleanupcode --- .../Resources/Annotations/AttrAttribute.cs | 5 +- .../Configuration/ResourceGraphBuilder.cs | 3 +- .../ResourceFieldChainExpression.cs | 4 +- .../Queries/FieldSelectors.cs | 32 +-- .../Queries/Parsing/FilterParser.cs | 36 +-- .../Queries/Parsing/QueryExpressionParser.cs | 4 +- .../Queries/Parsing/SortParser.cs | 12 +- .../Queries/Parsing/SparseFieldSetParser.cs | 4 +- .../QueryableBuilding/WhereClauseBuilder.cs | 8 +- .../Queries/SparseFieldSetCache.cs | 14 +- .../FieldChains/FieldChainPattern.cs | 4 +- .../QueryStrings/FieldChains/MatchState.cs | 10 +- .../Request/Adapters/ResourceObjectAdapter.cs | 3 +- .../Response/ResponseModelAdapter.cs | 17 +- .../CompoundAttributesFakers.cs | 15 +- .../CompoundAttributesReadTests.cs | 86 +++++-- .../CompoundAttributesSparseFieldSetTests.cs | 21 +- .../CompoundAttributesValidationTests.cs | 12 +- .../CompoundAttributesWriteTests.cs | 31 ++- .../FieldChainPatternParseTests.cs | 6 +- .../CompoundFilterParseTests.cs | 9 +- .../CompoundSortParseTests.cs | 4 +- .../CompoundSparseFieldSetParseTests.cs | 4 +- .../QueryableBuilding/ClauseBuilderTests.cs | 57 +++-- .../ResourceGraph/CompoundTypeTests.cs | 16 +- .../ResourceFieldAttributeTests.cs | 3 + .../ResourceGraphBuilderTests.cs | 236 +++++++++--------- 27 files changed, 375 insertions(+), 281 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 5f2735fd7d..cfd8ba459e 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -41,13 +41,12 @@ public AttrCapabilities Capabilities public bool IsCompound { get; set; } /// - /// Evaluates whether the underlying property is a collection. - /// Non-settable; evaluated on demand from . + /// Evaluates whether the underlying property is a collection. Non-settable; evaluated on demand from . /// public bool IsCollection => _isCollectionLazy.Value; /// - /// The schema of the nested compound type when is true; otherwise null. + /// The schema of the nested compound type when is true; otherwise null. /// public CompoundType? CompoundType { get; internal set; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index bc16fb0624..3158698091 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -493,8 +493,7 @@ private void AssertNoCyclesInCompoundTypes(Type currentType, HashSet visit { if (inPath.Contains(currentType)) { - throw new InvalidConfigurationException( - $"Circular reference detected: compound type '{currentType}' references itself directly or indirectly."); + throw new InvalidConfigurationException($"Circular reference detected: compound type '{currentType}' references itself directly or indirectly."); } if (!visited.Add(currentType)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 78705669d2..3d92471192 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -20,7 +20,9 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class ResourceFieldChainExpression : IdentifierExpression { /// - /// A list of one or more JSON:API fields. Use to convert from text. + /// A list of one or more JSON:API fields. Use + /// + /// to convert from text. /// public IImmutableList Fields { get; } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index 2fa8f63af6..f1cfefc3ec 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -26,6 +26,22 @@ public sealed class FieldSelectors : Dictionary ChildSelectors => _childSelectors ?? (IReadOnlyDictionary)ImmutableDictionary.Empty; + public bool ContainsReadOnlyAttribute + { + get + { + return this.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + } + } + + public bool ContainsOnlyRelationships + { + get + { + return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); + } + } + /// /// Gets child selectors for the specified compound attribute, or null if not present. /// @@ -52,22 +68,6 @@ public FieldSelectors GetOrCreateChildSelectors(AttrAttribute compoundAttribute) return childSelectors; } - public bool ContainsReadOnlyAttribute - { - get - { - return this.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); - } - } - - public bool ContainsOnlyRelationships - { - get - { - return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); - } - } - public bool ContainsField(ResourceFieldAttribute field) { ArgumentNullException.ThrowIfNull(field); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 372b251590..bfafefacd2 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -139,8 +139,8 @@ private CountExpression ParseCount() EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.None, ContainerInScope, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, + FieldChainPatternMatchOptions.None, ContainerInScope, null); EatSingleCharacterToken(TokenKind.CloseParen); @@ -330,9 +330,15 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal { bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); - string errorMessage = PrimitiveElementInScope != null - ? allowNull ? "Function, value between quotes, null or '_' expected." : "Function, value between quotes or '_' expected." - : allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; + // @formatter:nested_ternary_style expanded + string errorMessage = allowNull + ? PrimitiveElementInScope != null + ? "Function, value between quotes, '_' or null expected." + : "Function, field name, value between quotes or null expected." + : PrimitiveElementInScope != null + ? "Function, value between quotes or '_' expected." + : "Function, field name or value between quotes expected."; + // @formatter:nested_ternary_style restore if (TokenStack.TryPeek(out Token? nextToken)) { @@ -373,7 +379,8 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal throw new QueryParseException(errorMessage, nextToken.Position); } - return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, errorMessage); + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, + errorMessage); } } @@ -443,8 +450,8 @@ private QueryExpression ParseTextMatchLeftTerm() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, - ContainerInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -517,8 +524,8 @@ protected virtual AnyExpression ParseAny() throw new QueryParseException("Function or '_' expected.", position); } - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -537,8 +544,8 @@ protected virtual HasExpression ParseHas() EatText(Keywords.Has); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = - ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.None, ContainerInScope, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, + FieldChainPatternMatchOptions.None, ContainerInScope, null); FilterExpression? filter = null; @@ -564,8 +571,9 @@ protected virtual HasExpression ParseHas() } else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } primitiveCollectionAttr) { - Type elementType = CollectionConverter.Instance.FindCollectionElementType(primitiveCollectionAttr.Property.PropertyType) - ?? throw new InvalidOperationException($"Cannot determine element type for primitive collection attribute '{primitiveCollectionAttr.Property.Name}'."); + Type elementType = CollectionConverter.Instance.FindCollectionElementType(primitiveCollectionAttr.Property.PropertyType) ?? + throw new InvalidOperationException( + $"Cannot determine element type for primitive collection attribute '{primitiveCollectionAttr.Property.Name}'."); using (InScopeOfPrimitiveElement(elementType)) { diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index c39c68e391..0c572e9e84 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -94,9 +94,7 @@ protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern if (!result.IsSuccess) { - string containerDescription = container is ResourceType resourceType - ? $"resource type '{resourceType}'" - : $"compound type '{container}'"; + string containerDescription = container is ResourceType resourceType ? $"resource type '{resourceType}'" : $"compound type '{container}'"; string message = result.IsFieldChainError ? result.FailureMessage diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index 29be56c42e..d2b80bfc0e 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -11,6 +11,8 @@ namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public class SortParser : QueryExpressionParser, ISortParser { + private bool _isInsideCount; + /// public SortExpression Parse(string source, ResourceType resourceType) { @@ -91,7 +93,9 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy else { string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; - target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + + target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, + errorMessage); } return new SortElementExpression(target, isAscending); @@ -123,8 +127,6 @@ protected virtual FunctionExpression ParseFunction(ResourceType resourceType) throw new QueryParseException("Count function expected.", position); } - private bool _isInsideCount; - private CountExpression ParseCount(ResourceType resourceType) { EatText(Keywords.Count); @@ -136,8 +138,8 @@ private CountExpression ParseCount(ResourceType resourceType) try { - targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, - FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.AllowDerivedTypes, + resourceType, null); } finally { diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index c0aba82578..64e68f6259 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -38,8 +38,8 @@ public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = - ParseFieldChain(BuiltInPatterns.SparseFieldSetChain, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); + ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.SparseFieldSetChain, FieldChainPatternMatchOptions.None, resourceType, + "Field name expected."); fieldSetBuilder.Add(nextChain); } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index b08c988c7d..c9695946b3 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -50,14 +50,12 @@ public override Expression VisitHas(HasExpression expression, QueryClauseBuilder if (expression.Filter != null) { - ResourceType? nestedResourceType = expression.TargetCollection.Fields[^1] is HasManyAttribute hasMany - ? hasMany.RightType - : null; + ResourceType? nestedResourceType = expression.TargetCollection.Fields[^1] is HasManyAttribute hasMany ? hasMany.RightType : null; using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); - var nestedContext = new QueryClauseBuilderContext(property, nestedResourceType, typeof(Enumerable), context.EntityModel, - context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); + var nestedContext = new QueryClauseBuilderContext(property, nestedResourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + lambdaScope, context.QueryableBuilder, context.State); predicate = GetPredicateLambda(expression.Filter, nestedContext); } diff --git a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs index 809636593d..75040d4130 100644 --- a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -26,8 +26,7 @@ public SparseFieldSetCache(IEnumerable constraintProvi _lazySourceTable = new Lazy>(() => BuildSourceTable(constraintProviders)); } - private static Dictionary BuildSourceTable( - IEnumerable constraintProviders) + private static Dictionary BuildSourceTable(IEnumerable constraintProviders) { // @formatter:wrap_chained_method_calls chop_always // @formatter:wrap_before_first_method_call true @@ -86,16 +85,13 @@ public SparseFieldSetExpression GetSparseFieldSetExpressionForSerializer(Resourc if (!_visitedSerializerTable.TryGetValue(resourceType, out SparseFieldSetExpression? outputExpression)) { - SparseFieldSetExpression inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out SparseFieldSetExpression? inputFields) - ? inputFields - : GetCachedViewableFieldSet(resourceType); + 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); + outputExpression = callbackExpression == null ? GetCachedViewableFieldSet(resourceType) : IntersectExpressions(inputExpression, callbackExpression); _visitedSerializerTable[resourceType] = outputExpression; } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index 16eaf0ec6d..1e0d14624f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -192,8 +192,8 @@ public PatternMatchResult Match(string fieldChain, ResourceType resourceType, Fi /// /// The match result. /// - public PatternMatchResult Match(string fieldChain, IAttributeContainer container, 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(container); diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs index c9f137c291..f7381e2e23 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -15,6 +15,11 @@ 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. /// @@ -25,11 +30,6 @@ internal sealed class MatchState /// public IAttributeContainer? Container { get; } - /// - /// The resource type to find the next field on. - /// - private ResourceType? ResourceType => Container as ResourceType; - /// /// The fields matched against this pattern segment. /// diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index c22818ccee..d3a63ed940 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -87,8 +87,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje 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); + $"Failed to convert attribute '{attributeName}' with value '{attributeValue}' to type '{typeName}'.", innerException: exception); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 288b69fbcc..2ae30e0a51 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; @@ -274,12 +275,11 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc // Check if any field chains exist for this compound attribute with deeper paths List subChains = fieldSetExpression.FieldChains .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) - .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) - .ToList(); + .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); if (subChains.Count > 0) { - if (attr.IsCollection && value is System.Collections.IEnumerable collection) + if (attr.IsCollection && value is IEnumerable collection) { var prunedList = new List(); @@ -303,7 +303,8 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return attrMap.Count > 0 ? attrMap : null; } - private Dictionary PruneCompoundObject(object compoundInstance, CompoundType compoundType, IReadOnlyList fieldChains) + private Dictionary PruneCompoundObject(object compoundInstance, CompoundType compoundType, + IReadOnlyList fieldChains) { var prunedMap = new Dictionary(); HashSet directFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); @@ -330,14 +331,12 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (childValue != null && childAttr.IsCompound) { - List deeperChains = fieldChains - .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(childAttr)) - .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))) - .ToList(); + List deeperChains = fieldChains.Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(childAttr)) + .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); if (deeperChains.Count > 0) { - if (childAttr.IsCollection && childValue is System.Collections.IEnumerable collection) + if (childAttr.IsCollection && childValue is IEnumerable collection) { var prunedList = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index 9b960a36bb..b93d9eefa2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -1,6 +1,9 @@ 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 @@ -66,11 +69,19 @@ public CompoundAttributesFakers() .RuleFor(root => root.Addresses, _ => ContactAddress.GenerateList(1)) .RuleFor(root => root.EmailAddresses, faker => [ - new LabeledValue { Value = faker.Internet.Email(), Label = "primary" } + new LabeledValue + { + Value = faker.Internet.Email(), + Label = "primary" + } ]) .RuleFor(root => root.Relations, faker => [ - new LabeledValue { Value = faker.Name.FullName(), Label = "friend" } + new LabeledValue + { + Value = faker.Name.FullName(), + Label = "friend" + } ])); _lazyContactFaker = new Lazy>(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index d57ecdad41..c89670830b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -66,10 +66,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Verify JSON representation string responseRaw = await httpResponse.Content.ReadAsStringAsync(); using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement - .GetProperty("data") - .GetProperty("attributes") - .GetProperty("content"); + JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); contentElement.GetProperty("name").GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); contentElement.GetProperty("name").GetProperty("displayName").GetString().Should().Be(contact.Content.Name.DisplayName); @@ -159,14 +156,28 @@ 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" } + 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" } + new ContactPhoneNumber + { + Number = "333-333", + Label = "home" + } ]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -192,7 +203,13 @@ public async Task Can_filter_on_primitive_collection_using_has_and_element_value { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncUrls = ["https://example.com/sync", "https://backup.org"]; + + addressBooks[0].SyncUrls = + [ + "https://example.com/sync", + "https://backup.org" + ]; + addressBooks[1].SyncUrls = ["https://other.com/api"]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -244,15 +261,29 @@ 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" } + new ContactPhoneNumber + { + Number = "111-111" + }, + new ContactPhoneNumber + { + Number = "222-222" + }, + new ContactPhoneNumber + { + Number = "333-333" + } ]; + contacts[1].Content.PhoneNumbers = [ - new ContactPhoneNumber { Number = "444-444" } + new ContactPhoneNumber + { + Number = "444-444" + } ]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -278,7 +309,14 @@ public async Task Can_filter_by_count_on_primitive_collection() { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncUrls = ["url1", "url2", "url3"]; + + addressBooks[0].SyncUrls = + [ + "url1", + "url2", + "url3" + ]; + addressBooks[1].SyncUrls = ["url1"]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -396,12 +434,30 @@ public async Task Can_filter_to_many_relationship_with_compound_collection_condi // Arrange AddressBook addressBook1 = _fakers.AddressBook.Generate(); Contact contact1 = _fakers.Contact.Generate(); - contact1.Content.PhoneNumbers = [new ContactPhoneNumber { Number = "999", Label = "hotline" }]; + + 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" }]; + + contact2.Content.PhoneNumbers = + [ + new ContactPhoneNumber + { + Number = "111", + Label = "home" + } + ]; + addressBook2.Contacts.Add(contact2); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs index 2e67aeb71f..dccc180bad 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -50,10 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string responseRaw = await httpResponse.Content.ReadAsStringAsync(); using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement - .GetProperty("data") - .GetProperty("attributes") - .GetProperty("content"); + JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); @@ -91,10 +88,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string responseRaw = await httpResponse.Content.ReadAsStringAsync(); using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement - .GetProperty("data") - .GetProperty("attributes") - .GetProperty("content"); + JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); @@ -127,10 +121,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string responseRaw = await httpResponse.Content.ReadAsStringAsync(); using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement - .GetProperty("data") - .GetProperty("attributes") - .GetProperty("content"); + JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); contentElement.TryGetProperty("name", out JsonElement nameElement).Should().BeTrue(); nameElement.GetProperty("lastName").GetString().Should().Be(contact.Content.Name.LastName); @@ -243,12 +234,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string responseRaw = await httpResponse.Content.ReadAsStringAsync(); using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement favoritesElement = jsonDocument.RootElement - .GetProperty("data") - .GetProperty("attributes") - .GetProperty("favorites"); + JsonElement favoritesElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("favorites"); favoritesElement.GetArrayLength().Should().Be(2); + for (int i = 0; i < 2; i++) { JsonElement favorite = favoritesElement[i]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 7c8c34b4f6..53149e1a4d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -93,7 +93,9 @@ public async Task Cannot_navigate_directly_into_collection_without_has() 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 attributes, followed by a to-one relationship or an attribute or a compound attribute"); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute"); } [Fact] @@ -112,7 +114,9 @@ public async Task Cannot_filter_non_collection_with_has() 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection"); } [Fact] @@ -131,7 +135,9 @@ public async Task Cannot_use_count_on_non_collection() 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 attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + + error.Detail.Should().Contain( + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs index e6707e1d9d..540c29dada 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -47,8 +47,16 @@ public async Task Can_create_resource_with_compound_attributes() }, phoneNumbers = new[] { - new { number = "555-1234", label = "work" }, - new { number = "555-5678", label = "mobile" } + new + { + number = "555-1234", + label = "work" + }, + new + { + number = "555-5678", + label = "mobile" + } } }, remarks = "New contact note" @@ -158,7 +166,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, phoneNumbers = new[] { - new { number = "999-0000", label = "direct" } + new + { + number = "999-0000", + label = "direct" + } } } } @@ -235,7 +247,12 @@ public async Task Can_patch_resource_replacing_primitive_collection() { // Arrange AddressBook existingAddressBook = _fakers.AddressBook.Generate(); - existingAddressBook.SyncUrls = ["https://old1.com", "https://old2.com"]; + + existingAddressBook.SyncUrls = + [ + "https://old1.com", + "https://old2.com" + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -282,9 +299,13 @@ public async Task Can_patch_resource_clearing_compound_collection() { // Arrange Contact existingContact = _fakers.Contact.Generate(); + existingContact.Content.PhoneNumbers = [ - new ContactPhoneNumber { Number = "111-222" } + new ContactPhoneNumber + { + Number = "111-222" + } ]; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs index 73410b060d..33cc0aedd1 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -24,8 +24,10 @@ public sealed class FieldChainPatternParseTests [InlineData("[OC]", "a to-one relationship or a compound attribute")] [InlineData("[MPQ]", "a to-many relationship or a primitive collection or a compound collection")] [InlineData("[OC]*A", "zero or more to-one relationships or compound attributes, followed by an attribute")] - [InlineData("[OC]*[MPQ]", "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection")] - [InlineData("[OC]*[OAC]", "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute")] + [InlineData("[OC]*[MPQ]", + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[OC]*[OAC]", + "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute")] [InlineData("C*A", "zero or more compound attributes, followed by an attribute")] [InlineData("C+A", "one or more compound attributes, followed by an attribute")] [InlineData("[OC]?", "an optional to-one relationship or compound attribute")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index 0152744d5a..b0d43c45da 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -24,11 +24,12 @@ public sealed class CompoundFilterParseTests public CompoundFilterParseTests() { - var options = new JsonApiOptions { EnableLegacyFilterNotation = false }; + var options = new JsonApiOptions + { + EnableLegacyFilterNotation = false + }; - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); var request = new JsonApiRequest { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs index 9f2d7b8d65..a622d9bac8 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs @@ -25,9 +25,7 @@ public CompoundSortParseTests() { var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); var request = new JsonApiRequest { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs index 1b91d6091d..ce2cae0893 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs @@ -25,9 +25,7 @@ public CompoundSparseFieldSetParseTests() { var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); var request = new JsonApiRequest { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs index 9d19e4fded..9d4f4a3772 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.Design; using System.Linq.Expressions; using FluentAssertions; using JetBrains.Annotations; @@ -19,33 +20,26 @@ public sealed class ClauseBuilderTests : IDisposable { private readonly ResourceType _resourceType; private readonly IReadOnlyModel _entityModel; - private readonly System.ComponentModel.Design.ServiceContainer _serviceContainer; + private readonly ServiceContainer _serviceContainer; private readonly FilterParser _filterParser; private readonly SortParser _sortParser; public ClauseBuilderTests() { var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Build(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); _resourceType = resourceGraph.GetResourceType(); var modelBuilder = new ModelBuilder(); _entityModel = modelBuilder.Model.FinalizeModel(); - _serviceContainer = new System.ComponentModel.Design.ServiceContainer(); + _serviceContainer = new ServiceContainer(); var resourceFactory = new ResourceFactory(_serviceContainer); _filterParser = new FilterParser(resourceFactory); _sortParser = new SortParser(); } - public void Dispose() - { - _serviceContainer.Dispose(); - } - [Fact] public void Builds_where_clause_for_nested_compound_attribute() { @@ -57,8 +51,9 @@ public void Builds_where_clause_for_nested_compound_attribute() 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -78,8 +73,9 @@ public void Builds_where_clause_for_null_comparison_on_compound_attribute() 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -99,8 +95,9 @@ public void Builds_where_clause_for_compound_collection_has() 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -120,8 +117,9 @@ public void Builds_where_clause_for_primitive_collection_has_with_element_symbol 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -141,8 +139,9 @@ public void Builds_where_clause_for_primitive_collection_has_with_startsWith() 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -162,8 +161,9 @@ public void Builds_where_clause_for_collection_count() 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); + + var context = new QueryClauseBuilderContext(source.Expression, _resourceType, typeof(Queryable), _entityModel, lambdaScopeFactory, lambdaScope, + new DummyQueryableBuilder(), null); // Act Expression result = whereClauseBuilder.ApplyWhere(filter, context); @@ -183,14 +183,21 @@ public void Builds_order_by_clause_for_nested_scalar() 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); + + 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)"); + result.ToString().Should().Contain( + ".OrderByDescending(compoundModel => compoundModel.Details.Nested.Description).ThenBy(compoundModel => compoundModel.Details.Title)"); + } + + public void Dispose() + { + _serviceContainer.Dispose(); } private sealed class DummyQueryableBuilder : IQueryableBuilder diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs index e216df755f..ebe4b66aa5 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -23,6 +23,7 @@ public void Can_lookup_attributes_in_compound_type() { // Arrange PropertyInfo streetProperty = typeof(TestAddress).GetProperty(nameof(TestAddress.Street))!; + var streetAttribute = new AttrAttribute { PublicName = "street", @@ -40,13 +41,13 @@ public void Can_lookup_attributes_in_compound_type() 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'."); + 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'."); } @@ -56,9 +57,11 @@ 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()); + + var resourceGraph = new JsonApiDotNetCore.Configuration.ResourceGraph(new HashSet().AsReadOnly(), new HashSet + { + compoundType + }.AsReadOnly()); // Act & Assert resourceGraph.GetCompoundTypes().Should().ContainSingle().Which.Should().Be(compoundType); @@ -67,8 +70,7 @@ public void Can_lookup_compound_type_in_resource_graph() resourceGraph.GetCompoundType(typeof(TestAddress)).Should().Be(compoundType); Action getUnknown = () => resourceGraph.GetCompoundType(typeof(string)); - getUnknown.Should().ThrowExactly() - .WithMessage("Compound type 'System.String' does not exist in the resource graph."); + getUnknown.Should().ThrowExactly().WithMessage("Compound type 'System.String' does not exist in the resource graph."); } private sealed class TestAddress diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index e2a9966a2f..7dbebd6219 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -239,10 +239,12 @@ public void Evaluates_IsCollection_correctly() { Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.ScalarString))! }; + var listAttr = new AttrAttribute { Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.StringList))! }; + var setAttr = new AttrAttribute { Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.IntSet))! @@ -259,6 +261,7 @@ public void Can_set_and_get_IsCompound_and_CompoundType() { // Arrange var compoundType = new CompoundType(typeof(TestResource)); + var attribute = new AttrAttribute { IsCompound = true, diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index ea13cc23c9..3ae561bb28 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -445,124 +445,6 @@ public void Can_override_capabilities_on_Id_property() idAttribute.Capabilities.Should().Be(AttrCapabilities.AllowFilter); } - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithHasOneRelationship : Identifiable - { - [HasOne] - public ResourceWithAttribute? PrimaryChild { get; set; } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithHasManyRelationship : Identifiable - { - [HasMany] - public ISet Children { get; set; } = new HashSet(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithAttribute : Identifiable - { - [Attr] - public string? PrimaryValue { get; set; } - - [HasOne] - public ResourceWithAttribute? PrimaryChild { get; set; } - - [HasMany] - public ISet TopLevelChildren { get; set; } = new HashSet(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithDuplicateAttrPublicName : Identifiable - { - [Attr(PublicName = "duplicate")] - public string? Value1 { get; set; } - - [Attr(PublicName = "duplicate")] - public string? Value2 { get; set; } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithDuplicateRelationshipPublicName : Identifiable - { - [HasOne(PublicName = "duplicate")] - public ResourceWithHasOneRelationship? PrimaryChild { get; set; } - - [HasMany(PublicName = "duplicate")] - public ISet Children { get; set; } = new HashSet(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class ResourceWithDuplicateAttrRelationshipPublicName : Identifiable - { - [Attr(PublicName = "duplicate")] - public string? Value { get; set; } - - [HasMany(PublicName = "duplicate")] - public ISet Children { get; set; } = new HashSet(); - } - - private sealed class ResourceWithoutId : IIdentifiable - { - public string? StringId { get; set; } - public string? LocalId { get; set; } - } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class NonResource; - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - [NoResource] - private sealed class NonResourceWithSuppression; - - // ReSharper disable once ClassCanBeSealed.Global - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public class ResourceOfInt32 : Identifiable - { - [Attr] - public string? StringValue { get; set; } - - [HasOne] - public ResourceOfInt32? PrimaryChild { get; set; } - - [HasMany] - public IList Children { get; set; } = new List(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceWithIdOverride : Identifiable - { - [Attr(Capabilities = AttrCapabilities.AllowFilter)] - public override long Id { get; set; } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public abstract class AbstractBaseResource : Identifiable - { - [Attr(PublicName = "baseValue")] - public virtual int Value { get; set; } - - [HasOne(PublicName = "baseToOne")] - public virtual AbstractBaseResource? BaseToOne { get; set; } - - [HasMany(PublicName = "baseToMany")] - public virtual ISet BaseToMany { get; set; } = new HashSet(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DerivedAlternateAttributeName : AbstractBaseResource - { - [Attr(PublicName = "derivedValue")] - public override int Value { get; set; } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DerivedAlternateToOneRelationshipName : AbstractBaseResource - { - [HasOne(PublicName = "derivedToOne")] - public override AbstractBaseResource? BaseToOne { get; set; } - } - [Fact] public void Discovers_compound_types_recursively() { @@ -772,6 +654,124 @@ public void Supports_empty_base_class_in_compound_type_hierarchy() compoundType.Attributes.Should().ContainSingle(attr => attr.Property.Name == nameof(DerivedCompound.Value)); } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasOneRelationship : Identifiable + { + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasManyRelationship : Identifiable + { + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithAttribute : Identifiable + { + [Attr] + public string? PrimaryValue { get; set; } + + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + + [HasMany] + public ISet TopLevelChildren { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrPublicName : Identifiable + { + [Attr(PublicName = "duplicate")] + public string? Value1 { get; set; } + + [Attr(PublicName = "duplicate")] + public string? Value2 { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateRelationshipPublicName : Identifiable + { + [HasOne(PublicName = "duplicate")] + public ResourceWithHasOneRelationship? PrimaryChild { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrRelationshipPublicName : Identifiable + { + [Attr(PublicName = "duplicate")] + public string? Value { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet Children { get; set; } = new HashSet(); + } + + private sealed class ResourceWithoutId : IIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class NonResource; + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + [NoResource] + private sealed class NonResourceWithSuppression; + + // ReSharper disable once ClassCanBeSealed.Global + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public class ResourceOfInt32 : Identifiable + { + [Attr] + public string? StringValue { get; set; } + + [HasOne] + public ResourceOfInt32? PrimaryChild { get; set; } + + [HasMany] + public IList Children { get; set; } = new List(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithIdOverride : Identifiable + { + [Attr(Capabilities = AttrCapabilities.AllowFilter)] + public override long Id { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public abstract class AbstractBaseResource : Identifiable + { + [Attr(PublicName = "baseValue")] + public virtual int Value { get; set; } + + [HasOne(PublicName = "baseToOne")] + public virtual AbstractBaseResource? BaseToOne { get; set; } + + [HasMany(PublicName = "baseToMany")] + public virtual ISet BaseToMany { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DerivedAlternateAttributeName : AbstractBaseResource + { + [Attr(PublicName = "derivedValue")] + public override int Value { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DerivedAlternateToOneRelationshipName : AbstractBaseResource + { + [HasOne(PublicName = "derivedToOne")] + public override AbstractBaseResource? BaseToOne { get; set; } + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ResourceWithSortableCompound : Identifiable { From d3ac8082d8cdefcefcd2f84ad2780fb7db151067 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:17:47 +0200 Subject: [PATCH 27/63] Add workaround for coverlet bug --- Directory.Build.targets | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 @@ + + + + + From 8cb9dee0c57af60a9c28c753d5069cecd164290c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:04:35 +0200 Subject: [PATCH 28/63] Relocate EF Core 10 projection workaround from QueryLayerComposer to QueryableBuilder Co-authored-by: Cursor --- .../Queries/QueryLayerComposer.cs | 8 -- .../QueryableBuilding/QueryableBuilder.cs | 16 ++++ .../QueryableBuilding/SelectClauseBuilder.cs | 11 ++- .../QueryableBuilding/ClauseBuilderTests.cs | 93 ++++++++++++++++++- 4 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index 4c61c406b9..4cf5a96b98 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -612,14 +612,6 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection attr.IsCompound)) - { - continue; - } - FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); foreach (ResourceFieldChainExpression chain in fieldSetExpression.FieldChains) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index 1a1af59749..7052f7d031 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -135,9 +135,25 @@ 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 de8d0b7c3a..e489d5b113 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -215,7 +215,10 @@ private MemberAssignment CreatePropertyAssignment(PropertySelector propertySelec } else if (propertySelector.ChildSelectors is { IsEmpty: false }) { - assignmentRightHandSide = CreateCompoundMemberInit(propertySelector.ChildSelectors, propertyAccess, context); + if (CollectionConverter.Instance.FindCollectionElementType(propertySelector.Property.PropertyType) == null) + { + assignmentRightHandSide = CreateCompoundMemberInit(propertySelector.ChildSelectors, propertyAccess, context); + } } MemberAssignment assignment = Expression.Bind(propertySelector.Property, assignmentRightHandSide); @@ -236,7 +239,7 @@ private ConditionalExpression CreateCompoundMemberInit(FieldSelectors childSelec Expression childRightHandSide; - if (grandchildSelectors is { IsEmpty: false }) + if (grandchildSelectors is { IsEmpty: false } && !childAttr.IsCollection) { childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context); } @@ -265,7 +268,9 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Membe return CreateCollectionInitializer(selectorPropertyInfo, bodyElementType, layer, context); } - if (layer.Selection == null || layer.Selection.IsEmpty) + 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; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs index 9d4f4a3772..386d33c7eb 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -21,6 +21,7 @@ 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; @@ -32,11 +33,12 @@ public ClauseBuilderTests() _resourceType = resourceGraph.GetResourceType(); var modelBuilder = new ModelBuilder(); + modelBuilder.Entity(); _entityModel = modelBuilder.Model.FinalizeModel(); _serviceContainer = new ServiceContainer(); - var resourceFactory = new ResourceFactory(_serviceContainer); - _filterParser = new FilterParser(resourceFactory); + _resourceFactory = new ResourceFactory(_serviceContainer); + _filterParser = new FilterParser(_resourceFactory); _sortParser = new SortParser(); } @@ -195,6 +197,93 @@ public void Builds_order_by_clause_for_nested_scalar() ".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"); + } + public void Dispose() { _serviceContainer.Dispose(); From 71a98f390695bcd5271217117944873cf5c261fa Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:05:34 +0200 Subject: [PATCH 29/63] Include complex properties in SelectClauseBuilder.IncludeAllScalarProperties Co-authored-by: Cursor --- .../Queries/QueryableBuilding/SelectClauseBuilder.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index e489d5b113..9ee8ce272f 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -154,6 +154,12 @@ private static void IncludeAllScalarProperties(Type elementType, Dictionary navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) { From a2b1523d5d7858d4130ea635895f888b46623503 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:06:33 +0200 Subject: [PATCH 30/63] Clean up FieldSelection and FieldSelectors Co-authored-by: Cursor --- .../Queries/FieldSelection.cs | 10 ++++++---- .../Queries/FieldSelectors.cs | 18 +++--------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs index d3aabacf30..2f27a85587 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -58,22 +58,24 @@ 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 && GetOrCreateSelectors(type).GetChildSelectors(attr) is { } childSelectors) + if (field is AttrAttribute attr && selectors.GetChildSelectors(attr) is { } childSelectors) { WriteChildSelectors(writer, toFullString, childSelectors); } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index f1cfefc3ec..6f921fd6bd 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -82,19 +82,6 @@ public void IncludeAttribute(AttrAttribute attribute) this[attribute] = null; } - /// - /// Includes the compound attribute along with its nested child field selectors. - /// - public void IncludeCompoundAttribute(AttrAttribute compoundAttribute, FieldSelectors childSelectors) - { - ArgumentNullException.ThrowIfNull(compoundAttribute); - ArgumentNullException.ThrowIfNull(childSelectors); - - this[compoundAttribute] = null; - _childSelectors ??= new Dictionary(); - _childSelectors[compoundAttribute] = childSelectors; - } - public void IncludeAttributes(IEnumerable attributes) { ArgumentNullException.ThrowIfNull(attributes); @@ -115,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(); } } From 176c1eb7d8ac4c8767a77bc0b8f3194b1562fb74 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:10:26 +0200 Subject: [PATCH 31/63] Rename PrimitiveCollection to ScalarCollection and update token from 'P' to 'L' Co-authored-by: Cursor --- .../FieldChains/BuiltInPatterns.cs | 2 +- .../FieldChains/FieldChainPattern.cs | 6 +-- .../FieldChains/FieldTypeExtensions.cs | 4 +- .../QueryStrings/FieldChains/FieldTypes.cs | 4 +- .../FieldChains/PatternMatcher.cs | 2 +- .../QueryStrings/FieldChains/PatternParser.cs | 6 +-- .../FieldChains/PatternTextFormatter.cs | 4 +- .../CompoundAttributesValidationTests.cs | 4 +- .../FieldChainPatternMatchTests.cs | 40 +++++++++---------- .../FieldChainPatternParseTests.cs | 14 +++---- .../CompoundSortParseTests.cs | 4 +- .../LegacyFilterParseTests.cs | 8 ++-- .../QueryStringParameters/SortParseTests.cs | 8 ++-- 13 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 5c936bbf76..bf7818094f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -13,7 +13,7 @@ public static class BuiltInPatterns public static FieldChainPattern ToOneOrCompoundChainEndingInAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); public static FieldChainPattern ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound { get; } = FieldChainPattern.Parse("[OC]*[AOC]"); - public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MPQ]"); + public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MLQ]"); public static FieldChainPattern SparseFieldSetChain { get; } = FieldChainPattern.Parse("[CQ]*F"); [Obsolete("""This is no longer used and will be removed in a future version. Instead, use: FieldChainPattern.Parse("R*M")""")] diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index 1e0d14624f..0e168fa15d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -16,7 +16,7 @@ public sealed class FieldChainPattern public const char RelationshipToken = 'R'; public const char AttributeToken = 'A'; public const char CompoundAttributeToken = 'C'; - public const char PrimitiveCollectionToken = 'P'; + public const char ScalarCollectionToken = 'L'; public const char CompoundCollectionToken = 'Q'; public const char FieldToken = 'F'; @@ -93,9 +93,9 @@ internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, /// /// /// - /// P + /// L /// - /// Matches a primitive collection. + /// Matches a scalar collection. /// /// /// diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs index 8dd35dc121..29d0e300b1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -39,9 +39,9 @@ public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool WriteChoice("compound attribute", pluralize, prefix, false, builder, startOffset); } - if (choices.HasFlag(FieldTypes.PrimitiveCollection)) + if (choices.HasFlag(FieldTypes.ScalarCollection)) { - WriteChoice("primitive collection", pluralize, prefix, false, builder, startOffset); + WriteChoice("scalar collection", pluralize, prefix, false, builder, startOffset); } if (choices.HasFlag(FieldTypes.CompoundCollection)) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs index 3f6c503641..d9f1f43872 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -8,9 +8,9 @@ internal enum FieldTypes ToOneRelationship = 1 << 1, ToManyRelationship = 1 << 2, CompoundAttribute = 1 << 3, - PrimitiveCollection = 1 << 4, + ScalarCollection = 1 << 4, CompoundCollection = 1 << 5, Relationship = ToOneRelationship | ToManyRelationship, - Field = ScalarAttribute | Relationship | CompoundAttribute | PrimitiveCollection | CompoundCollection + Field = ScalarAttribute | Relationship | CompoundAttribute | ScalarCollection | CompoundCollection } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 32084b58c5..23ec457cee 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -244,7 +244,7 @@ private static FieldTypes GetFieldType(ResourceFieldAttribute? field) RelationshipAttribute => FieldTypes.Relationship, AttrAttribute { IsCompound: true, IsCollection: true } => FieldTypes.CompoundCollection, AttrAttribute { IsCompound: true, IsCollection: false } => FieldTypes.CompoundAttribute, - AttrAttribute { IsCompound: false, IsCollection: true } => FieldTypes.PrimitiveCollection, + AttrAttribute { IsCompound: false, IsCollection: true } => FieldTypes.ScalarCollection, 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 9026965c4a..fab51cbee6 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -17,7 +17,7 @@ internal sealed class PatternParser ['R'] = Token.Relationship, ['A'] = Token.ScalarAttribute, ['C'] = Token.CompoundAttribute, - ['P'] = Token.PrimitiveCollection, + ['L'] = Token.ScalarCollection, ['Q'] = Token.CompoundCollection, ['F'] = Token.Field }; @@ -29,7 +29,7 @@ internal sealed class PatternParser [Token.Relationship] = FieldTypes.Relationship, [Token.ScalarAttribute] = FieldTypes.ScalarAttribute, [Token.CompoundAttribute] = FieldTypes.CompoundAttribute, - [Token.PrimitiveCollection] = FieldTypes.PrimitiveCollection, + [Token.ScalarCollection] = FieldTypes.ScalarCollection, [Token.CompoundCollection] = FieldTypes.CompoundCollection, [Token.Field] = FieldTypes.Field }; @@ -188,7 +188,7 @@ private enum Token Relationship, ScalarAttribute, CompoundAttribute, - PrimitiveCollection, + ScalarCollection, CompoundCollection, Field } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs index b85a4c773a..ba49443f9a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -68,9 +68,9 @@ private static void WriteChoices(FieldTypes types, StringBuilder builder) builder.Append('C'); } - if (types.HasFlag(FieldTypes.PrimitiveCollection)) + if (types.HasFlag(FieldTypes.ScalarCollection)) { - builder.Append('P'); + builder.Append('L'); } if (types.HasFlag(FieldTypes.CompoundCollection)) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 53149e1a4d..2dc1897f55 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -116,7 +116,7 @@ public async Task Cannot_filter_non_collection_with_has() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain( - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection"); } [Fact] @@ -137,7 +137,7 @@ public async Task Cannot_use_count_on_non_collection() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain( - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection"); + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 15bf27860c..f632642b5e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -22,12 +22,12 @@ public sealed class FieldChainPatternMatchTests : IDisposable private const string O = "parent"; private const string M = "children"; private const string C = "details"; - private const string P = "tags"; + 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 P2 = "numbers"; + private const string L2 = "numbers"; private readonly LoggerFactory _loggerFactory; private readonly ResourceType _resourceType; @@ -100,16 +100,16 @@ public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) [InlineData("MOA", $"{M}.{O}.{A}")] // Compound and collection field types [InlineData("C", C)] - [InlineData("P", P)] + [InlineData("L", L)] [InlineData("Q", Q)] [InlineData("F", C)] - [InlineData("F", P)] + [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("CP", $"{C}.{P2}")] + [InlineData("CL", $"{C}.{L2}")] [InlineData("CQ", $"{C}.{Q}")] // Compound character classes and quantifiers [InlineData("[OC]", O)] @@ -119,16 +119,16 @@ public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) [InlineData("[OC]*A", $"{C}.{A2}")] [InlineData("[OC]*A", $"{C}.{C2}.{A3}")] [InlineData("[OC]*A", $"{O}.{C}.{C2}.{A3}")] - [InlineData("[MPQ]", M)] - [InlineData("[MPQ]", P)] - [InlineData("[MPQ]", Q)] - [InlineData("[OC]*[MPQ]", M)] - [InlineData("[OC]*[MPQ]", P)] - [InlineData("[OC]*[MPQ]", Q)] - [InlineData("[OC]*[MPQ]", $"{C}.{P2}")] - [InlineData("[OC]*[MPQ]", $"{C}.{Q}")] - [InlineData("[OC]*[MPQ]", $"{O}.{M}")] - [InlineData("[OC]*[MPQ]", $"{O}.{C}.{Q}")] + [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)] @@ -433,13 +433,13 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("F+R*A", $"{M}.{M}.{M}.{O}.{M}.{M}.{M}.{O}^", $"Field on resource type '{T}' expected.")] // Compound & collection mismatch [InlineData("A", $"^{C}", $"Attribute on resource type '{T}' expected.")] - [InlineData("A", $"^{P}", $"Attribute on resource type '{T}' expected.")] + [InlineData("A", $"^{L}", $"Attribute on resource type '{T}' expected.")] [InlineData("A", $"^{Q}", $"Attribute on resource type '{T}' expected.")] [InlineData("C", $"^{A}", $"Compound attribute on resource type '{T}' expected.")] - [InlineData("C", $"^{P}", $"Compound attribute on resource type '{T}' expected.")] - [InlineData("P", $"^{A}", $"Primitive collection on resource type '{T}' expected.")] - [InlineData("P", $"^{Q}", $"Primitive collection on resource type '{T}' expected.")] - [InlineData("Q", $"^{P}", $"Compound collection on resource type '{T}' expected.")] + [InlineData("C", $"^{L}", $"Compound attribute on resource type '{T}' expected.")] + [InlineData("L", $"^{A}", $"Scalar collection on resource type '{T}' expected.")] + [InlineData("L", $"^{Q}", $"Scalar collection on resource type '{T}' expected.")] + [InlineData("Q", $"^{L}", $"Compound collection on resource type '{T}' expected.")] // Navigation on compound type [InlineData("CA", $"{C}.^{C2}", "Attribute on compound type 'CompoundRoot' expected.")] [InlineData("CA", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs index 33cc0aedd1..a67c30177e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -19,13 +19,13 @@ public sealed class FieldChainPatternParseTests [InlineData("O*[MA]", "zero or more to-one relationships, followed by a to-many relationship or an attribute")] [InlineData("O?M+A", "an optional to-one relationship, followed by one or more to-many relationships, followed by an attribute")] [InlineData("C", "a compound attribute")] - [InlineData("P", "a primitive collection")] + [InlineData("L", "a scalar collection")] [InlineData("Q", "a compound collection")] [InlineData("[OC]", "a to-one relationship or a compound attribute")] - [InlineData("[MPQ]", "a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[MLQ]", "a to-many relationship or a scalar collection or a compound collection")] [InlineData("[OC]*A", "zero or more to-one relationships or compound attributes, followed by an attribute")] - [InlineData("[OC]*[MPQ]", - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection")] + [InlineData("[OC]*[MLQ]", + "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection")] [InlineData("[OC]*[OAC]", "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute")] [InlineData("C*A", "zero or more compound attributes, followed by an attribute")] @@ -75,11 +75,11 @@ public void ParseFails(string patternText, string errorMessage) [InlineData("[MORAF]", "F", "a field")] [InlineData("[AMAM]", "[MA]", "a to-many relationship or an attribute")] [InlineData("[C]", "C", "a compound attribute")] - [InlineData("[P]", "P", "a primitive collection")] + [InlineData("[L]", "L", "a scalar collection")] [InlineData("[Q]", "Q", "a compound collection")] [InlineData("[OCOC]", "[OC]", "a to-one relationship or a compound attribute")] - [InlineData("[QPMP]", "[MPQ]", "a to-many relationship or a primitive collection or a compound collection")] - [InlineData("[MORACPQ]", "F", "a field")] + [InlineData("[QLML]", "[MLQ]", "a to-many relationship or a scalar collection or a compound collection")] + [InlineData("[MORACLQ]", "F", "a field")] public void ParseNormalizesChoices(string patternText, string reducedText, string displayText) { // Act diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs index a622d9bac8..f9f19abb35 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs @@ -68,8 +68,8 @@ public void Reader_Read_Compound_Sort_Succeeds(string parameterName, string para "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + "To-one relationship or attribute or compound attribute on resource type 'compoundModels' expected.")] [InlineData("sort", "count(details^)", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + - "Relationship or compound attribute or primitive collection or compound collection on compound type 'CompoundRoot' expected.")] + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + + "Relationship or compound attribute or scalar collection or compound collection on compound type 'CompoundRoot' expected.")] public void Reader_Read_Compound_Sort_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index 4719147c01..73ff377b2e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -63,11 +63,11 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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:has(author)", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + - "Relationship or compound attribute or primitive collection or compound collection on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + + "Relationship or compound attribute or scalar collection or compound collection 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 or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + - "Relationship or compound attribute or primitive collection or compound collection on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + + "Relationship or compound attribute or scalar collection or compound collection on resource type 'webAccounts' expected.")] public void Reader_Read_ParameterValue_Fails(string parameterName, string parameterValue, string errorMessage) { // Act diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index 4db9440ce4..4af066cb5d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -107,11 +107,11 @@ 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 or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + - "Relationship or compound attribute or primitive collection or compound collection on resource type 'blogs' expected.")] + "Field chain on resource type 'blogs' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + + "Relationship or compound attribute or scalar collection or compound collection 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 or compound attributes, followed by a to-many relationship or a primitive collection or a compound collection. " + - "Relationship or compound attribute or primitive collection or compound collection on resource type 'webAccounts' expected.")] + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + + "Relationship or compound attribute or scalar collection or compound collection 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.")] From 9146e7f6d1baf5c3be207148eb6b94a0f21dc114 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:15:14 +0200 Subject: [PATCH 32/63] Rename ElementValueExpression to CollectionElementValueExpression and update FilterParser Co-authored-by: Cursor --- ...cs => CollectionElementValueExpression.cs} | 12 +-- .../Expressions/QueryExpressionRewriter.cs | 2 +- .../Expressions/QueryExpressionVisitor.cs | 2 +- .../Queries/Parsing/FilterParser.cs | 82 +++++++++---------- .../QueryableBuilding/WhereClauseBuilder.cs | 4 +- .../UnitTests/Queries/QueryExpressionTests.cs | 4 +- .../CompoundFilterParseTests.cs | 16 ++-- 7 files changed, 61 insertions(+), 61 deletions(-) rename src/JsonApiDotNetCore/Queries/Expressions/{ElementValueExpression.cs => CollectionElementValueExpression.cs} (58%) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs similarity index 58% rename from src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs index de5ceb9814..37a4fceaf9 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ElementValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs @@ -3,17 +3,17 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the element value of a primitive collection in a collection filter (such as within ). +/// Represents the element value of a collection (such as a scalar collection) in an expression tree. /// [PublicAPI] -public sealed class ElementValueExpression : IdentifierExpression +public sealed class CollectionElementValueExpression : IdentifierExpression { /// - /// The CLR type of the primitive collection element (e.g. , ). + /// The CLR type of the collection element (e.g. , ). /// public Type ElementType { get; } - public ElementValueExpression(Type elementType) + public CollectionElementValueExpression(Type elementType) { ArgumentNullException.ThrowIfNull(elementType); ElementType = elementType; @@ -21,7 +21,7 @@ public ElementValueExpression(Type elementType) public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { - return visitor.VisitElementValue(this, argument); + return visitor.VisitCollectionElementValue(this, argument); } public override string ToString() @@ -36,7 +36,7 @@ public override string ToFullString() public override bool Equals(object? obj) { - return ReferenceEquals(this, obj) || (obj is ElementValueExpression other && ElementType == other.ElementType); + return ReferenceEquals(this, obj) || (obj is CollectionElementValueExpression other && ElementType == other.ElementType); } public override int GetHashCode() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index a034b7193b..6f62bdfa20 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -51,7 +51,7 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } - public override QueryExpression VisitElementValue(ElementValueExpression expression, TArgument argument) + public override QueryExpression VisitCollectionElementValue(CollectionElementValueExpression expression, TArgument argument) { return expression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index 494f25f4ce..fdd9abd9f9 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -44,7 +44,7 @@ public virtual TResult VisitNullConstant(NullConstantExpression expression, TArg return DefaultVisit(expression, argument); } - public virtual TResult VisitElementValue(ElementValueExpression expression, TArgument argument) + public virtual TResult VisitCollectionElementValue(CollectionElementValueExpression expression, TArgument argument) { return DefaultVisit(expression, argument); } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index bfafefacd2..abc58f94e6 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -35,12 +35,12 @@ public class FilterParser : QueryExpressionParser, IFilterParser private readonly IResourceFactory _resourceFactory; private readonly Stack _containerStack = new(); - private readonly Stack _primitiveElementStack = new(); + private readonly Stack _scalarCollectionElementStack = new(); /// - /// Gets the CLR type of the primitive collection element currently in scope, or null if not in scope of a primitive collection. + /// Gets the CLR type of the scalar collection element currently in scope, or null if not in scope of a scalar collection. /// - protected Type? PrimitiveElementInScope => _primitiveElementStack.Count > 0 ? _primitiveElementStack.Peek() : null; + protected Type? ScalarCollectionElementInScope => _scalarCollectionElementStack.Count > 0 ? _scalarCollectionElementStack.Peek() : null; /// /// Gets the container currently in scope. Call to temporarily change the current container. @@ -89,7 +89,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); _containerStack.Clear(); - _primitiveElementStack.Clear(); + _scalarCollectionElementStack.Clear(); FilterExpression expression; using (InScopeOfResourceType(resourceType)) @@ -100,7 +100,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) } AssertContainerStackIsEmpty(); - AssertPrimitiveElementStackIsEmpty(); + AssertScalarCollectionElementStackIsEmpty(); return expression; } @@ -130,10 +130,10 @@ protected virtual FunctionExpression ParseFunction() private CountExpression ParseCount() { - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'count' cannot be used on primitive collection elements.", position); + throw new QueryParseException("Function 'count' cannot be used on scalar collection elements.", position); } EatText(Keywords.Count); @@ -267,19 +267,19 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe return ParseFunction(); } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { if (nextToken.Value == Keywords.Element) { EatText(Keywords.Element); - return new ElementValueExpression(PrimitiveElementInScope); + return new CollectionElementValueExpression(ScalarCollectionElementInScope); } throw new QueryParseException("Function or '_' expected.", nextToken.Position); } } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function or '_' expected.", position); @@ -295,7 +295,7 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) { - if (leftTerm is ElementValueExpression elementValue) + if (leftTerm is CollectionElementValueExpression elementValue) { ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(elementValue.ElementType); return ParseTypedComparisonRightTerm(elementValue.ElementType, constantValueConverter); @@ -332,10 +332,10 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal // @formatter:nested_ternary_style expanded string errorMessage = allowNull - ? PrimitiveElementInScope != null + ? ScalarCollectionElementInScope != null ? "Function, value between quotes, '_' or null expected." : "Function, field name, value between quotes or null expected." - : PrimitiveElementInScope != null + : ScalarCollectionElementInScope != null ? "Function, value between quotes or '_' expected." : "Function, field name or value between quotes expected."; // @formatter:nested_ternary_style restore @@ -368,12 +368,12 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { if (nextToken.Value == Keywords.Element) { EatText(Keywords.Element); - return new ElementValueExpression(PrimitiveElementInScope); + return new CollectionElementValueExpression(ScalarCollectionElementInScope); } throw new QueryParseException(errorMessage, nextToken.Position); @@ -424,25 +424,25 @@ private QueryExpression ParseTextMatchLeftTerm() return targetFunction; } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { if (nextToken.Value == Keywords.Element) { EatText(Keywords.Element); - if (PrimitiveElementInScope != typeof(string)) + if (ScalarCollectionElementInScope != typeof(string)) { throw new QueryParseException("Element of type 'String' expected.", nextToken.Position); } - return new ElementValueExpression(PrimitiveElementInScope); + return new CollectionElementValueExpression(ScalarCollectionElementInScope); } throw new QueryParseException("Function or '_' expected.", nextToken.Position); } } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function or '_' expected.", position); @@ -506,19 +506,19 @@ protected virtual AnyExpression ParseAny() return (targetFunction, functionConverterFactory); } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { if (nextToken.Value == Keywords.Element) { EatText(Keywords.Element); - return (new ElementValueExpression(PrimitiveElementInScope), () => GetConstantValueConverterForType(PrimitiveElementInScope)); + return (new CollectionElementValueExpression(ScalarCollectionElementInScope), () => GetConstantValueConverterForType(ScalarCollectionElementInScope)); } throw new QueryParseException("Function or '_' expected.", nextToken.Position); } } - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function or '_' expected.", position); @@ -535,10 +535,10 @@ protected virtual AnyExpression ParseAny() protected virtual HasExpression ParseHas() { - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'has' cannot be used on primitive collection elements.", position); + throw new QueryParseException("Function 'has' cannot be used on scalar collection elements.", position); } EatText(Keywords.Has); @@ -569,13 +569,13 @@ protected virtual HasExpression ParseHas() filter = ParseFilter(); } } - else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } primitiveCollectionAttr) + else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } scalarCollectionAttr) { - Type elementType = CollectionConverter.Instance.FindCollectionElementType(primitiveCollectionAttr.Property.PropertyType) ?? + Type elementType = CollectionConverter.Instance.FindCollectionElementType(scalarCollectionAttr.Property.PropertyType) ?? throw new InvalidOperationException( - $"Cannot determine element type for primitive collection attribute '{primitiveCollectionAttr.Property.Name}'."); + $"Cannot determine element type for scalar collection attribute '{scalarCollectionAttr.Property.Name}'."); - using (InScopeOfPrimitiveElement(elementType)) + using (InScopeOfScalarCollectionElement(elementType)) { filter = ParseFilter(); } @@ -589,10 +589,10 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { - if (PrimitiveElementInScope != null) + if (ScalarCollectionElementInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'isType' cannot be used on primitive collection elements.", position); + throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); } if (ContainerInScope is not ResourceType) @@ -791,14 +791,14 @@ protected IDisposable InScopeOfContainer(IAttributeContainer container) } /// - /// Changes the primitive element type currently in scope and restores the original type when the return value is disposed. + /// Changes the scalar collection element type currently in scope and restores the original type when the return value is disposed. /// - protected IDisposable InScopeOfPrimitiveElement(Type elementType) + protected IDisposable InScopeOfScalarCollectionElement(Type elementType) { ArgumentNullException.ThrowIfNull(elementType); - _primitiveElementStack.Push(elementType); - return new PopPrimitiveElementOnDispose(_primitiveElementStack); + _scalarCollectionElementStack.Push(elementType); + return new PopScalarCollectionElementOnDispose(_scalarCollectionElementStack); } private void AssertContainerStackIsEmpty() @@ -810,12 +810,12 @@ private void AssertContainerStackIsEmpty() } } - private void AssertPrimitiveElementStackIsEmpty() + private void AssertScalarCollectionElementStackIsEmpty() { - if (_primitiveElementStack.Count > 0) + if (_scalarCollectionElementStack.Count > 0) { - throw new InvalidOperationException("There is still a primitive element type in scope after parsing has completed. " + - $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfPrimitiveElement)}()."); + 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(InScopeOfScalarCollectionElement)}()."); } } @@ -829,13 +829,13 @@ public void Dispose() } } - private sealed class PopPrimitiveElementOnDispose(Stack primitiveElementStack) : IDisposable + private sealed class PopScalarCollectionElementOnDispose(Stack scalarCollectionElementStack) : IDisposable { - private readonly Stack _primitiveElementStack = primitiveElementStack; + private readonly Stack _scalarCollectionElementStack = scalarCollectionElementStack; public void Dispose() { - _primitiveElementStack.Pop(); + _scalarCollectionElementStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index c9695946b3..c36694df90 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -216,7 +216,7 @@ private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderCont return typeof(int); } - if (expression is ElementValueExpression elementValue) + if (expression is CollectionElementValueExpression elementValue) { return elementValue.ElementType; } @@ -252,7 +252,7 @@ public override Expression VisitLiteralConstant(LiteralConstantExpression expres return SystemExpressionBuilder.CloseOver(expression.TypedValue); } - public override Expression VisitElementValue(ElementValueExpression expression, QueryClauseBuilderContext context) + public override Expression VisitCollectionElementValue(CollectionElementValueExpression expression, QueryClauseBuilderContext context) { return context.LambdaScope.Accessor; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs index 4bfbc81db3..9ad840a800 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs @@ -339,9 +339,9 @@ public CountExpression Count() return new CountExpression(ResourceFieldChainForChildren()); } - public ElementValueExpression ElementValue() + public CollectionElementValueExpression ElementValue() { - return new ElementValueExpression(typeof(string)); + return new CollectionElementValueExpression(typeof(string)); } public HasExpression Has() diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index b0d43c45da..25dee2882d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -85,9 +85,9 @@ public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string pa [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or '_' expected.")] [InlineData("filter", "has(tags,equals(^unknown,'work'))", "Function or '_' expected.")] - [InlineData("filter", "has(tags,^has(tags,equals(_,'w')))", "Function 'has' cannot be used on primitive collection elements.")] - [InlineData("filter", "has(tags,equals(^count(tags),'1'))", "Function 'count' cannot be used on primitive collection elements.")] - [InlineData("filter", "has(tags,^isType(,some))", "Function 'isType' cannot be used on primitive collection elements.")] + [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' cannot be used on scalar collection elements.")] public void Reader_Read_Compound_Filter_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -110,11 +110,11 @@ public void Reader_Read_Compound_Filter_Fails(string parameterName, string param } [Fact] - public void ElementValueExpression_Properties_And_Equality() + public void CollectionElementValueExpression_Properties_And_Equality() { - var expr1 = new ElementValueExpression(typeof(string)); - var expr2 = new ElementValueExpression(typeof(string)); - var expr3 = new ElementValueExpression(typeof(int)); + var expr1 = new CollectionElementValueExpression(typeof(string)); + var expr2 = new CollectionElementValueExpression(typeof(string)); + var expr3 = new CollectionElementValueExpression(typeof(int)); expr1.ElementType.Should().Be(); expr1.ToString().Should().Be("_"); @@ -123,7 +123,7 @@ public void ElementValueExpression_Properties_And_Equality() expr1.GetHashCode().Should().Be(expr2.GetHashCode()); expr1.Should().NotBe(expr3); - Action createWithNull = () => _ = new ElementValueExpression(null!); + Action createWithNull = () => _ = new CollectionElementValueExpression(null!); createWithNull.Should().ThrowExactly(); } From 694d8af9fcb6dffa290d1e123ce74327471f0cdc Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:17:15 +0200 Subject: [PATCH 33/63] Add throwing attribute getters to IAttributeContainer Co-authored-by: Cursor --- .../Configuration/CompoundType.cs | 8 ++------ .../Configuration/IAttributeContainer.cs | 10 ++++++++++ .../Configuration/ResourceType.cs | 4 ++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index ed17790d18..814648046f 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -32,9 +32,7 @@ public CompoundType(Type clrType, IReadOnlyCollection? attributes } } - /// - /// Gets the attribute for the specified publicly exposed name. Throws an when not found. - /// + /// public AttrAttribute GetAttributeByPublicName(string publicName) { AttrAttribute? attribute = FindAttributeByPublicName(publicName); @@ -49,9 +47,7 @@ public AttrAttribute GetAttributeByPublicName(string publicName) return _attributesByPublicName.GetValueOrDefault(publicName); } - /// - /// Gets the attribute for the specified property name. Throws an when not found. - /// + /// public AttrAttribute GetAttributeByPropertyName(string propertyName) { AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs index dba17965da..2dd6bd0be7 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs @@ -19,11 +19,21 @@ public interface IAttributeContainer /// 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. /// diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 9258ed160f..791b072dc0 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -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); From 252f00aa48523f0ed5ea45057405669857d0a013 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:19:26 +0200 Subject: [PATCH 34/63] Complete generic convenience overloads on IResourceGraph and ResourceGraph Co-authored-by: Cursor --- .../Configuration/IResourceGraph.cs | 16 +++++++++++++ .../Configuration/ResourceGraph.cs | 19 +++++++++++++++ .../ResourceGraphBuilderTests.cs | 23 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index e4c20dc348..b0303c006e 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -43,6 +43,12 @@ 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. /// @@ -53,11 +59,21 @@ ResourceType GetResourceType() /// 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. /// diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 0c0429eef3..2b2f38e0d6 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -114,6 +114,12 @@ public CompoundType GetCompoundType(Type clrType) return compoundType; } + /// + public CompoundType GetCompoundType() + { + return GetCompoundType(typeof(TCompound)); + } + /// public CompoundType? FindCompoundType(Type clrType) { @@ -122,6 +128,12 @@ public CompoundType GetCompoundType(Type clrType) return _compoundTypesByClrType.GetValueOrDefault(clrType); } + /// + public CompoundType? FindCompoundType() + { + return FindCompoundType(typeof(TCompound)); + } + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) { return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; @@ -134,6 +146,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 diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 3ae561bb28..74e3f7971c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -491,6 +491,29 @@ public void Discovers_compound_types_recursively() resourceGraph.GetCompoundType(typeof(TestPhoneCompound)).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 Automatically_strips_AllowSort_from_compound_attributes() { From 4aabb3afa710b56cfc2219f90c4d85a9d8329fd3 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:22:18 +0200 Subject: [PATCH 35/63] Assign IsCollection in ResourceGraphBuilder, document unsupported STJ attributes, clean up dead code Co-authored-by: Cursor --- .../Configuration/CompoundType.cs | 6 +++++ .../Resources/Annotations/AttrAttribute.cs | 21 ++++++---------- .../Configuration/ResourceGraphBuilder.cs | 9 +++---- .../Errors/InvalidModelStateException.cs | 4 ++- .../ResourceFieldAttributeTests.cs | 25 +++++++++---------- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index 814648046f..19facd5feb 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -6,6 +6,12 @@ namespace JsonApiDotNetCore.Configuration; /// /// Metadata about the shape of an embedded compound value object in the resource graph. /// +/// +/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API +/// resource and compound models and are never taken into account when reading request bodies, rendering responses, +/// or generating error source pointers. Schema names and capabilities are governed exclusively by +/// [Attr(PublicName)] and [Attr(Capabilities)]. +/// [PublicAPI] public sealed class CompoundType : IAttributeContainer { diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index cfd8ba459e..ed5bbbc122 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -8,11 +8,16 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// /// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). /// +/// +/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API +/// resource and compound models and are never taken into account when reading request bodies, rendering responses, +/// or generating error source pointers. Schema names and capabilities are governed exclusively by +/// [Attr(PublicName)] and [Attr(Capabilities)]. +/// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] public sealed class AttrAttribute : ResourceFieldAttribute { - private readonly Lazy _isCollectionLazy; private AttrCapabilities? _capabilities; internal bool HasExplicitCapabilities => _capabilities != null; @@ -41,25 +46,15 @@ public AttrCapabilities Capabilities public bool IsCompound { get; set; } /// - /// Evaluates whether the underlying property is a collection. Non-settable; evaluated on demand from . + /// Indicates whether the underlying property is a collection. /// - public bool IsCollection => _isCollectionLazy.Value; + public bool IsCollection { get; internal set; } /// /// The schema of the nested compound type when is true; otherwise null. /// public CompoundType? CompoundType { get; internal set; } - public AttrAttribute() - { - _isCollectionLazy = new Lazy(EvaluateIsCollection, LazyThreadSafetyMode.PublicationOnly); - } - - private bool EvaluateIsCollection() - { - return Property.PropertyType != typeof(string) && CollectionConverter.Instance.FindCollectionElementType(Property.PropertyType) != null; - } - /// public override bool Equals(object? obj) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 3158698091..6cee230583 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -256,11 +256,6 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st return this; } - if (_compoundTypesByClrType.ContainsKey(resourceClrType)) - { - throw new InvalidConfigurationException($"Type '{resourceClrType}' cannot be both a resource type and a compound type."); - } - if (resourceClrType.IsOrImplementsInterface()) { string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); @@ -351,6 +346,8 @@ 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) { @@ -451,6 +448,8 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu { SetPublicName(attribute, property); attribute.Property = property; + attribute.IsCollection = property.PropertyType != typeof(string) && + CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null; if (!attribute.HasExplicitCapabilities) { diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 0f82f6ffea..badd6fac68 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -213,7 +213,9 @@ private abstract class ModelStateKeySegment // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). public Type ModelType { get; } - // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. + // Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API + // resource and compound models and are never taken into account when reading request bodies, rendering responses, + // or generating error source pointers. Schema names and capabilities are governed exclusively by [Attr(PublicName)] and [Attr(Capabilities)]. public bool IsInComplexType { get; } // The source pointer we've built up, so far. This is null whenever input is not recognized. diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index 7dbebd6219..825d133ff7 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; @@ -235,25 +236,23 @@ public void Cannot_set_value_to_incompatible_value() public void Evaluates_IsCollection_correctly() { // Arrange - var scalarAttr = new AttrAttribute - { - Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.ScalarString))! - }; - - var listAttr = new AttrAttribute - { - Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.StringList))! - }; + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); - var setAttr = new AttrAttribute - { - Property = typeof(TestResourceWithCollections).GetProperty(nameof(TestResourceWithCollections.IntSet))! - }; + 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] From 296243303414a1d1ea1daabbeaa3073b8d0b386c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:26:31 +0200 Subject: [PATCH 36/63] Update InvalidModelStateException with CompoundType lookup and RFC 6901 array pointers Co-authored-by: Cursor --- .../Errors/InvalidModelStateException.cs | 35 +++++++++++-------- .../ModelStateValidationTests.cs | 35 +++++++++++++------ 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index badd6fac68..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/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; } } } From b9f4a06ab89280f2e7fdeabfb465f16b2fcb8e6c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:35:58 +0200 Subject: [PATCH 37/63] Preserve hierarchical field chains in SparseFieldSetExpressionExtensions Co-authored-by: Cursor --- .../SparseFieldSetExpressionExtensions.cs | 64 +++++++- ...SparseFieldSetExpressionExtensionsTests.cs | 138 ++++++++++++++++++ 2 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 936071ffd1..4507f03845 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -27,15 +27,28 @@ public static class SparseFieldSetExpressionExtensions return newSparseFieldSet; } + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, + ResourceFieldChainExpression fieldChain) + { + ArgumentNullException.ThrowIfNull(fieldChain); + + return IncludeFieldChain(sparseFieldSet, fieldChain); + } + private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) { - if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) + return IncludeFieldChain(sparseFieldSet, new ResourceFieldChainExpression(fieldToInclude)); + } + + private static SparseFieldSetExpression? IncludeFieldChain(SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression chainToInclude) + { + if (sparseFieldSet == null || sparseFieldSet.FieldChains.Contains(chainToInclude)) { return sparseFieldSet; } - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude); - return new SparseFieldSetExpression(newSparseFieldSet); + IImmutableSet newFieldChains = sparseFieldSet.FieldChains.Add(chainToInclude); + return new SparseFieldSetExpression(newFieldChains); } public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, @@ -55,19 +68,58 @@ public static class SparseFieldSetExpressionExtensions return newSparseFieldSet; } + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, + ResourceFieldChainExpression fieldChain) + { + ArgumentNullException.ThrowIfNull(fieldChain); + + return ExcludeFieldChain(sparseFieldSet, fieldChain); + } + private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) + { + return ExcludeFieldChain(sparseFieldSet, new ResourceFieldChainExpression(fieldToExclude)); + } + + private static SparseFieldSetExpression? ExcludeFieldChain(SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression chainToExclude) { // 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. - if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) + if (sparseFieldSet == null) + { + return null; + } + + ImmutableHashSet newFieldChains = sparseFieldSet.FieldChains + .Where(chain => !IsPrefixOrEqual(chainToExclude, chain)) + .ToImmutableHashSet(); + + if (newFieldChains.Count == sparseFieldSet.FieldChains.Count) { return sparseFieldSet; } - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude); - return new SparseFieldSetExpression(newSparseFieldSet); + return newFieldChains.IsEmpty ? null : new SparseFieldSetExpression(newFieldChains); + } + + private static bool IsPrefixOrEqual(ResourceFieldChainExpression prefix, ResourceFieldChainExpression target) + { + if (prefix.Fields.Count > target.Fields.Count) + { + return false; + } + + for (int index = 0; index < prefix.Fields.Count; index++) + { + if (!prefix.Fields[index].Equals(target.Fields[index])) + { + return false; + } + } + + return true; } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs new file mode 100644 index 0000000000..198d64f477 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs @@ -0,0 +1,138 @@ +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_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)); + + var nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); + var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Remarks, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.Should().HaveCount(2); + updatedSet.FieldChains.Should().Contain(nestedChain); + AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); + updatedSet.FieldChains.Should().Contain(new ResourceFieldChainExpression(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 nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); + var remarksChain = new ResourceFieldChainExpression(remarksAttr); + var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain, remarksChain)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Remarks, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.Should().HaveCount(1); + updatedSet.FieldChains.Should().Contain(nestedChain); + 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 nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); + var remarksChain = new ResourceFieldChainExpression(remarksAttr); + var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain, remarksChain)); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Content, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.Should().HaveCount(1); + updatedSet.FieldChains.Should().Contain(remarksChain); + updatedSet.ToString().Should().Be("remarks"); + } + + [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? LastName { get; set; } + } +} From c0036cb6f4174191015d9bc5789e99b60ba80a61 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:40:07 +0200 Subject: [PATCH 38/63] Add GetFieldChain lambda resolution to IResourceGraph and ResourceGraph Co-authored-by: Cursor --- .../Configuration/IResourceGraph.cs | 32 ++++ .../Configuration/ResourceGraph.cs | 157 ++++++++++++++++++ .../SparseFieldSetExpressionExtensions.cs | 8 +- ...SparseFieldSetExpressionExtensionsTests.cs | 52 ++++++ .../ResourceGraphBuilderTests.cs | 30 ++++ 5 files changed, 275 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index b0303c006e..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; @@ -115,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/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 2b2f38e0d6..a759569401 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; @@ -180,6 +182,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(typeof(TResource)); + return ResolveFieldChains(selector, resourceType); + } + + private IReadOnlyCollection 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( + $"Member '{member.Name}' 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( + $"Member '{field.Property.Name}' 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/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 4507f03845..d017ea7d31 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -19,9 +19,9 @@ public static class SparseFieldSetExpressionExtensions SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) { - newSparseFieldSet = IncludeField(newSparseFieldSet, field); + newSparseFieldSet = IncludeFieldChain(newSparseFieldSet, chain); } return newSparseFieldSet; @@ -60,9 +60,9 @@ public static class SparseFieldSetExpressionExtensions SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) { - newSparseFieldSet = ExcludeField(newSparseFieldSet, field); + newSparseFieldSet = ExcludeFieldChain(newSparseFieldSet, chain); } return newSparseFieldSet; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs index 198d64f477..8ed80703bf 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs @@ -100,6 +100,58 @@ public void Excluding_top_level_compound_removes_all_its_nested_chains() updatedSet.ToString().Should().Be("remarks"); } + [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(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.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)); + var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))) + .Including(c => c.Content!.Name!.LastName, _resourceGraph); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Excluding(c => c.Content!.Name!.LastName, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.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(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))); + + // Act + SparseFieldSetExpression? updatedSet = initialSet.Including(c => new { c.Remarks, c.Content!.Name!.LastName }, _resourceGraph); + + // Assert + updatedSet.Should().NotBeNull(); + updatedSet!.FieldChains.Should().HaveCount(2); + updatedSet.ToString().Should().Be("content.name.lastName,remarks"); + } + [Fact] public void Including_or_Excluding_when_null_returns_null() { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 74e3f7971c..638ce5ab4e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; @@ -514,6 +515,35 @@ public void Generic_Find_and_Get_overloads_on_ResourceGraph_succeed_and_fail_as_ 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() { From d36637d012c600a4dd5c44ae5abdf57dab583cd8 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:42:12 +0200 Subject: [PATCH 39/63] Enforce AllowView, custom PublicName, and null guards during read serialization Co-authored-by: Cursor --- .../Response/ResponseModelAdapter.cs | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 2ae30e0a51..0a9b86e481 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -277,23 +277,22 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); - if (subChains.Count > 0) - { - if (attr.IsCollection && value is IEnumerable collection) - { - var prunedList = new List(); + var context = new CompoundSelectionContext(subChains.Count > 0 ? subChains : null); - foreach (object item in collection) - { - prunedList.Add(PruneCompoundObject(item, attr.CompoundType!, subChains)); - } + if (attr.IsCollection && value is IEnumerable collection) + { + var prunedList = new List(); - value = prunedList; - } - else + foreach (object? item in collection) { - value = PruneCompoundObject(value, attr.CompoundType!, subChains); + prunedList.Add(item == null ? null : ConvertCompoundObject(item, attr.CompoundType!, context)); } + + value = prunedList; + } + else + { + value = ConvertCompoundObject(value, attr.CompoundType!, context); } } @@ -303,15 +302,19 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return attrMap.Count > 0 ? attrMap : null; } - private Dictionary PruneCompoundObject(object compoundInstance, CompoundType compoundType, - IReadOnlyList fieldChains) + private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, + CompoundSelectionContext context) { var prunedMap = new Dictionary(); - HashSet directFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); foreach (AttrAttribute childAttr in compoundType.Attributes) { - if (!directFields.Contains(childAttr)) + if (childAttr.IsViewBlocked()) + { + continue; + } + + if (context.DirectFields != null && !context.DirectFields.Contains(childAttr)) { continue; } @@ -331,26 +334,23 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (childValue != null && childAttr.IsCompound) { - List deeperChains = fieldChains.Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(childAttr)) - .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); + List? deeperChains = context.DeeperChainsByAttribute?.GetValueOrDefault(childAttr); + var childContext = new CompoundSelectionContext(deeperChains); - if (deeperChains.Count > 0) + if (childAttr.IsCollection && childValue is IEnumerable collection) { - if (childAttr.IsCollection && childValue is IEnumerable collection) - { - var prunedList = new List(); + var prunedList = new List(); - foreach (object item in collection) - { - prunedList.Add(PruneCompoundObject(item, childAttr.CompoundType!, deeperChains)); - } - - childValue = prunedList; - } - else + foreach (object? item in collection) { - childValue = PruneCompoundObject(childValue, childAttr.CompoundType!, deeperChains); + prunedList.Add(item == null ? null : ConvertCompoundObject(item, childAttr.CompoundType!, childContext)); } + + childValue = prunedList; + } + else + { + childValue = ConvertCompoundObject(childValue, childAttr.CompoundType!, childContext); } } @@ -360,6 +360,24 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return prunedMap; } + private readonly struct CompoundSelectionContext + { + public HashSet? DirectFields { get; } + public Dictionary>? DeeperChainsByAttribute { get; } + + public CompoundSelectionContext(IReadOnlyList? fieldChains) + { + if (fieldChains is { Count: > 0 }) + { + DirectFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); + DeeperChainsByAttribute = fieldChains + .Where(c => c.Fields.Count > 1) + .GroupBy(c => c.Fields[0]) + .ToDictionary(g => g.Key, g => g.Select(c => new ResourceFieldChainExpression(c.Fields.RemoveAt(0))).ToList()); + } + } + } + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, EndpointKind kind) { From bab18d5c772f8935c76f4c52954c1c04b0c0c885 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:03 +0200 Subject: [PATCH 40/63] Enforce AllowCreate, AllowChange, read-only, and unknown fields during write deserialization Co-authored-by: Cursor --- .../JsonConverters/ResourceObjectConverter.cs | 4 + .../Request/Adapters/ResourceObjectAdapter.cs | 182 +++++++++++++++++- .../CompoundAttributesReadTests.cs | 18 +- 3 files changed, 182 insertions(+), 22 deletions(-) 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 d3a63ed940..676a0aece2 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -68,7 +68,23 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); AssertNotReadOnly(attr, resourceType, state); - if (attributeValue != null && !attr.Property.PropertyType.IsInstanceOfType(attributeValue)) + 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 { @@ -76,7 +92,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje { attributeValue = jsonElement.Deserialize(attr.Property.PropertyType, _options.SerializerReadOptions); } - else if (attr.IsCompound || attr.IsCollection) + else if (attr.IsCollection) { JsonElement serializedElement = JsonSerializer.SerializeToElement(attributeValue, _options.SerializerReadOptions); attributeValue = serializedElement.Deserialize(attr.Property.PropertyType, _options.SerializerReadOptions); @@ -95,12 +111,155 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje 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) + { + Array 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, nonPublic: 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}'."); } } @@ -121,30 +280,33 @@ 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) { + string containerKind = container is ResourceType ? "resource type" : "compound type"; throw new ModelConversionException(state.Position, "Attribute is read-only.", - $"Attribute '{attr}' on resource type '{resourceType}' is read-only."); + $"Attribute '{attr}' on {containerKind} '{container}' is read-only."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index c89670830b..5eb025aa52 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -49,24 +49,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); object? contentValue = responseDocument.Data.SingleValue.Attributes!["content"]; - contentValue.Should().BeOfType(); + contentValue.Should().BeOfType(); - var content = (ContactRoot)contentValue; - content.Name.LastName.Should().Be(contact.Content.Name.LastName); - content.Name.DisplayName.Should().Be(contact.Content.Name.DisplayName); + 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) { - content.Company.Should().NotBeNull(); - content.Company!.Name.Should().Be(contact.Content.Company.Name); + contentElement.GetProperty("company").GetProperty("name").GetString().Should().Be(contact.Content.Company.Name); } - content.PhoneNumbers.Should().HaveCount(contact.Content.PhoneNumbers.Count); - - // Verify JSON representation - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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); From 37510feaefea5a2ebd8387e2df3dc174bde68f4d Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:55:36 +0200 Subject: [PATCH 41/63] Pass SerializerOptions to ResourceChangeTracker for accurate change detection Co-authored-by: Cursor --- .../Resources/ResourceChangeTracker.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index ecbc3e03ad..39b24be763 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,5 +1,6 @@ using System.Text.Json; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; @@ -10,6 +11,7 @@ namespace JsonApiDotNetCore.Resources; public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { + private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; @@ -17,11 +19,13 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _requestAttributeValues; private Dictionary? _finallyStoredAttributeValues; - public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) + public ResourceChangeTracker(IJsonApiOptions options, IJsonApiRequest request, ITargetedFields targetedFields) { + ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(targetedFields); + _options = options; _request = request; _targetedFields = targetedFields; } @@ -50,7 +54,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private static Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { var result = new Dictionary(); @@ -60,7 +64,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) if (value != null && (attribute.IsCompound || attribute.IsCollection)) { - value = JsonSerializer.SerializeToElement(value); + value = JsonSerializer.SerializeToElement(value, _options.SerializerOptions); } result.Add(attribute.PublicName, value); From 806be91bb194f3fc9ca104334b7206a935a908a5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:56:58 +0200 Subject: [PATCH 42/63] Add explanatory comments for non-obvious design choices and document record constructor rules Co-authored-by: Cursor --- .../Configuration/CompoundType.cs | 6 ++++++ .../Resources/Annotations/ResourceFieldAttribute.cs | 1 + src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs | 2 ++ .../Queries/QueryableBuilding/SelectClauseBuilder.cs | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index 19facd5feb..a09968d45a 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -7,10 +7,16 @@ namespace JsonApiDotNetCore.Configuration; /// Metadata about the shape of an embedded compound value object in the resource graph. /// /// +/// +/// Compound types used with sparse fieldsets or request deserialization must provide an accessible parameterless constructor +/// (can be non-public) to support member initialization expressions and object instantiation. +/// +/// /// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API /// resource and compound models and are never taken into account when reading request bodies, rendering responses, /// or generating error source pointers. Schema names and capabilities are governed exclusively by /// [Attr(PublicName)] and [Attr(Capabilities)]. +/// /// [PublicAPI] public sealed class CompoundType : IAttributeContainer diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index f9b39efccc..ce970a73b3 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -115,6 +115,7 @@ public virtual void SetValue(object resource, object? newValue) protected void AssertIsIdentifiable(object? resource) { + // 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 diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index d2b80bfc0e..b6553f8d83 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -155,6 +155,8 @@ protected override void ValidateField(ResourceFieldAttribute field, int position { ArgumentNullException.ThrowIfNull(field); + // 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]*A' ensures only leaf scalar attributes are evaluated. if (!_isInsideCount && field is AttrAttribute { IsCompound: false, IsCollection: false } attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 9ee8ce272f..385ddc7fbc 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -233,6 +233,8 @@ private MemberAssignment CreatePropertyAssignment(PropertySelector propertySelec private ConditionalExpression CreateCompoundMemberInit(FieldSelectors childSelectors, MemberExpression propertyAccess, QueryClauseBuilderContext context) { + // Direct projection of compound types via MemberInitExpression is constructed here for compound sparse fieldsets, + // but bypassed at the root entity level due to EF Core complex type limitations (https://github.com/dotnet/efcore/issues/39322). Type compoundClrType = propertyAccess.Type; var assignments = new List(); @@ -274,6 +276,8 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Membe return CreateCollectionInitializer(selectorPropertyInfo, bodyElementType, layer, context); } + // 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))) From af9845407ec7dcf9e004aa44b9108a9840ec23f1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:02:27 +0200 Subject: [PATCH 43/63] Expand CompoundAttributes test models, removing required keywords and updating fakers and tests Co-authored-by: Cursor --- .../CompoundAttributesFakers.cs | 28 +++++++-- .../CompoundAttributesReadTests.cs | 18 +++--- .../CompoundAttributesSparseFieldSetTests.cs | 4 +- .../CompoundAttributesWriteTests.cs | 21 +++---- .../CompoundAttributes/Entities.cs | 58 +++++++++++++++---- 5 files changed, 90 insertions(+), 39 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index b93d9eefa2..2a735624a2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -32,13 +32,16 @@ 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.DisplayName, (_, name) => $"{name.FirstName} {name.LastName}")); + .RuleFor(name => name.NickName, faker => faker.Internet.UserName()) + .RuleFor(name => name.MaidenName, faker => faker.Name.LastName())); _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.Department, faker => faker.Commerce.Department()) + .RuleFor(company => company.BusinessHours, _ => [TimeSpan.FromHours(9), TimeSpan.FromHours(17)])); _lazyContactAddressFaker = new Lazy>(() => new Faker() .MakeDeterministic() @@ -65,6 +68,8 @@ public CompoundAttributesFakers() .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 => @@ -82,6 +87,14 @@ public CompoundAttributesFakers() 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() @@ -94,10 +107,15 @@ public CompoundAttributesFakers() .RuleFor(book => book.Name, faker => faker.Commerce.Department()) .RuleFor(book => book.EmergencyContact, _ => ContactRoot.Generate()) .RuleFor(book => book.Favorites, _ => ContactRoot.GenerateList(2)) - .RuleFor(book => book.SyncUrls, faker => + .RuleFor(book => book.Categories, faker => + [ + faker.Commerce.Categories(1)[0], + faker.Commerce.Categories(1)[0] + ]) + .RuleFor(book => book.SyncDeviceIds, faker => [ - faker.Internet.Url(), - faker.Internet.Url() + faker.Random.Guid(), + faker.Random.Guid() ])); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index 5eb025aa52..3cc21be934 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -198,13 +198,13 @@ public async Task Can_filter_on_primitive_collection_using_has_and_element_value // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncUrls = + addressBooks[0].Categories = [ "https://example.com/sync", "https://backup.org" ]; - addressBooks[1].SyncUrls = ["https://other.com/api"]; + addressBooks[1].Categories = ["https://other.com/api"]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -213,7 +213,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/addressBooks?filter=has(syncUrls,equals(_,'https://example.com/sync'))"; + const string route = "/addressBooks?filter=has(categories,equals(_,'https://example.com/sync'))"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -229,8 +229,8 @@ public async Task Can_filter_on_primitive_collection_using_has_and_text_matching { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncUrls = ["https://contoso.com/sync"]; - addressBooks[1].SyncUrls = ["https://fabrikam.com/sync"]; + addressBooks[0].Categories = ["https://contoso.com/sync"]; + addressBooks[1].Categories = ["https://fabrikam.com/sync"]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -239,7 +239,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/addressBooks?filter=has(syncUrls,contains(_,'contoso'))"; + const string route = "/addressBooks?filter=has(categories,contains(_,'contoso'))"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -304,14 +304,14 @@ public async Task Can_filter_by_count_on_primitive_collection() // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncUrls = + addressBooks[0].Categories = [ "url1", "url2", "url3" ]; - addressBooks[1].SyncUrls = ["url1"]; + addressBooks[1].Categories = ["url1"]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -320,7 +320,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/addressBooks?filter=greaterThan(count(syncUrls),'1')"; + const string route = "/addressBooks?filter=greaterThan(count(categories),'1')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs index dccc180bad..5e5fdbaa26 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -167,7 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=syncUrls"; + string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=categories"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -177,7 +177,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("name"); - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("syncUrls"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("categories"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs index 540c29dada..ca4fb6cc2f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -37,8 +37,7 @@ public async Task Can_create_resource_with_compound_attributes() name = new { firstName = "John", - lastName = "Doe", - displayName = "John Doe" + lastName = "Doe" }, company = new { @@ -105,7 +104,7 @@ public async Task Can_create_resource_with_primitive_collection() attributes = new { name = "Work Address Book", - syncUrls = new[] + categories = new[] { "https://example.com/sync", "https://backup.example.com" @@ -130,8 +129,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(long.Parse(newAddressBookId)); addressBookInDatabase.Name.Should().Be("Work Address Book"); - addressBookInDatabase.SyncUrls.Should().NotBeNull(); - addressBookInDatabase.SyncUrls.Should().BeEquivalentTo("https://example.com/sync", "https://backup.example.com"); + addressBookInDatabase.Categories.Should().NotBeNull(); + addressBookInDatabase.Categories.Should().BeEquivalentTo("https://example.com/sync", "https://backup.example.com"); }); } @@ -161,8 +160,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => name = new { firstName = "Jane", - lastName = "Smith", - displayName = "Jane Smith" + lastName = "Smith" }, phoneNumbers = new[] { @@ -248,7 +246,7 @@ public async Task Can_patch_resource_replacing_primitive_collection() // Arrange AddressBook existingAddressBook = _fakers.AddressBook.Generate(); - existingAddressBook.SyncUrls = + existingAddressBook.Categories = [ "https://old1.com", "https://old2.com" @@ -269,7 +267,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingAddressBook.StringId, attributes = new { - syncUrls = new[] + categories = new[] { "https://new1.com" } @@ -290,7 +288,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { AddressBook addressBookInDatabase = await dbContext.AddressBooks.FirstWithIdAsync(existingAddressBook.Id); - addressBookInDatabase.SyncUrls.Should().BeEquivalentTo("https://new1.com"); + addressBookInDatabase.Categories.Should().BeEquivalentTo("https://new1.com"); }); } @@ -328,8 +326,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => name = new { firstName = existingContact.Content.Name.FirstName, - lastName = existingContact.Content.Name.LastName, - displayName = existingContact.Content.Name.DisplayName + lastName = existingContact.Content.Name.LastName }, phoneNumbers = Array.Empty() } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs index ae140465ed..8ce2b58382 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs @@ -18,8 +18,13 @@ public sealed class AddressBook : Identifiable [Attr(IsCompound = true)] public List Favorites { get; set; } = []; + // Scalar collection of strings for text-matching filter tests (startsWith, contains, endsWith) [Attr] - public List? SyncUrls { get; set; } + 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(); @@ -30,7 +35,7 @@ public sealed class AddressBook : Identifiable public sealed class Contact : Identifiable { [Attr(IsCompound = true)] - public required ContactRoot Content { get; set; } + public ContactRoot Content { get; set; } = null!; [Attr] public string? Remarks { get; set; } @@ -58,7 +63,7 @@ public sealed class ContactNote : Identifiable public sealed class ContactRoot { [Attr(IsCompound = true)] - public required ContactName Name { get; set; } + public ContactName Name { get; set; } = null!; [Attr(IsCompound = true)] public ContactCompany? Company { get; set; } @@ -72,11 +77,23 @@ public sealed class ContactRoot [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)] @@ -86,35 +103,52 @@ public sealed class ContactName public string? FirstName { get; set; } [Attr] - public required string LastName { get; set; } + public string? MiddleName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + // Calculated read-only property (get-only; no setter) [Attr] - public required string DisplayName { get; set; } + 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; } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ContactCompany { [Attr] - public required string Name { get; set; } + 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 required string IsoCountryCode { get; set; } + public string IsoCountryCode { get; set; } = null!; [Attr] - public required string City { get; set; } + public string City { get; set; } = null!; [Attr] public string? Line1 { get; set; } - [Attr] + // Custom public name "zipCode"; JsonPropertyName is neither needed nor used + [Attr(PublicName = "zipCode")] public string? PostalCode { get; set; } [Attr] @@ -127,8 +161,10 @@ public sealed class ContactPhoneNumber [Attr] public string? CountryPrefix { get; set; } + // [Required] attribute on collection element to test write-side ModelState validation [Attr] - public required string Number { get; set; } + [Required] + public string Number { get; set; } = null!; [Attr] public string? Label { get; set; } @@ -156,7 +192,7 @@ public sealed class ContactDate public sealed class LabeledValue { [Attr] - public required T Value { get; set; } + public T Value { get; set; } = default!; [Attr] public string? Label { get; set; } From 786a8674843504b75c74cead7b7bca99a367d24b Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:33:35 +0200 Subject: [PATCH 44/63] Add integration tests for expanded read scenarios Co-authored-by: Cursor --- .../CompoundAttributesFakers.cs | 2 +- .../CompoundAttributesReadTests.cs | 463 ++++++++++++++++++ .../CompoundAttributesSparseFieldSetTests.cs | 148 ++++++ 3 files changed, 612 insertions(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index 2a735624a2..bf0e1945e1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -19,9 +19,9 @@ internal sealed class CompoundAttributesFakers private Faker ContactName => _lazyContactNameFaker.Value; private Faker ContactAddress => _lazyContactAddressFaker.Value; - private Faker ContactPhoneNumber => _lazyContactPhoneNumberFaker.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; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index 3cc21be934..3d83d38e01 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -472,4 +472,467 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); + Guid 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_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 index 5e5fdbaa26..5b4fa8eb22 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -247,4 +247,152 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement content = jsonDocument.RootElement.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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement content = jsonDocument.RootElement.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, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().ContainKey("name"); + responseDocument.Included[0].Attributes.Should().NotContainKey("categories"); + + string responseRaw = await httpResponse.Content.ReadAsStringAsync(); + using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); + JsonElement content = jsonDocument.RootElement.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); + }); + } } From 7930449576833c314ac130c97fc7cff47d7baf70 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:56 +0200 Subject: [PATCH 45/63] Add integration tests for expanded write scenarios and validation Co-authored-by: Cursor --- .../Configuration/JsonApiValidationFilter.cs | 36 +- .../CompoundAttributesValidationTests.cs | 355 ++++++++++++++++++ .../CompoundAttributesWriteTests.cs | 309 +++++++++++++++ 3 files changed, 694 insertions(+), 6 deletions(-) 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/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 2dc1897f55..6c350ed028 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -10,6 +10,7 @@ public sealed class CompoundAttributesValidationTests : IClassFixture, CompoundAttributesDbContext>> { private readonly IntegrationTestContext, CompoundAttributesDbContext> _testContext; + private readonly CompoundAttributesFakers _fakers = new(); public CompoundAttributesValidationTests(IntegrationTestContext, CompoundAttributesDbContext> testContext) { @@ -158,4 +159,358 @@ public async Task Cannot_use_isType_inside_compound_attribute() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain("Function 'isType' cannot be used on compound types"); } + + [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 = "", + 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 index ca4fb6cc2f..476dc0ddd0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -1,6 +1,8 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -350,4 +352,311 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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(); + } } From 51f3a746d23e05d65d7f094054bf1a171e59189a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:28:34 +0200 Subject: [PATCH 46/63] Code cleanup --- .../Configuration/CompoundType.cs | 11 +- .../Resources/Annotations/AttrAttribute.cs | 7 +- .../Configuration/ResourceGraph.cs | 22 ++-- .../Configuration/ResourceGraphBuilder.cs | 2 + .../SparseFieldSetExpressionExtensions.cs | 9 +- .../Queries/Parsing/FilterParser.cs | 4 +- .../QueryableBuilding/QueryableBuilder.cs | 3 +- .../QueryableBuilding/SelectClauseBuilder.cs | 3 +- .../Request/Adapters/ResourceObjectAdapter.cs | 23 ++-- .../Response/ResponseModelAdapter.cs | 38 +++--- .../ServiceDiscoveryFacadeTests.cs | 6 +- .../CompoundAttributesFakers.cs | 13 ++- .../CompoundAttributesReadTests.cs | 108 +++++++++++++++--- .../CompoundAttributesSparseFieldSetTests.cs | 1 + .../CompoundAttributesValidationTests.cs | 2 +- .../CompoundAttributesWriteTests.cs | 28 +++-- .../CompoundAttributes/Entities.cs | 8 +- ...SparseFieldSetExpressionExtensionsTests.cs | 25 ++-- .../ResourceGraph/CompoundTypeTests.cs | 8 +- .../ResourceFieldAttributeTests.cs | 8 +- .../ResourceGraphBuilderTests.cs | 28 +++-- 21 files changed, 234 insertions(+), 123 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index a09968d45a..94df710c05 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -8,14 +8,13 @@ namespace JsonApiDotNetCore.Configuration; /// /// /// -/// Compound types used with sparse fieldsets or request deserialization must provide an accessible parameterless constructor -/// (can be non-public) to support member initialization expressions and object instantiation. +/// Compound types used with sparse fieldsets or request deserialization must provide an accessible parameterless constructor (can be non-public) to +/// support member initialization expressions and object instantiation. /// /// -/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API -/// resource and compound models and are never taken into account when reading request bodies, rendering responses, -/// or generating error source pointers. Schema names and capabilities are governed exclusively by -/// [Attr(PublicName)] and [Attr(Capabilities)]. +/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API resource and compound models and are +/// never taken into account when reading request bodies, rendering responses, or generating error source pointers. Schema names and capabilities are +/// governed exclusively by [Attr(PublicName)] and [Attr(Capabilities)]. /// /// [PublicAPI] diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index ed5bbbc122..e44bb8811d 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -9,10 +9,9 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). /// /// -/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API -/// resource and compound models and are never taken into account when reading request bodies, rendering responses, -/// or generating error source pointers. Schema names and capabilities are governed exclusively by -/// [Attr(PublicName)] and [Attr(Capabilities)]. +/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API resource and compound models and are +/// never taken into account when reading request bodies, rendering responses, or generating error source pointers. Schema names and capabilities are +/// governed exclusively by [Attr(PublicName)] and [Attr(Capabilities)]. /// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index a759569401..3ae7e74135 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -204,12 +204,12 @@ public IReadOnlyCollection GetFieldChains(); return ResolveFieldChains(selector, resourceType); } - private IReadOnlyCollection ResolveFieldChains( - Expression> selector, ResourceType resourceType) + private ReadOnlyCollection ResolveFieldChains(Expression> selector, + ResourceType resourceType) { Expression selectorBody = RemoveConvert(selector.Body); ParameterExpression parameter = selector.Parameters[0]; @@ -228,8 +228,8 @@ private IReadOnlyCollection ResolveFieldChains ExtractMemberChain(MemberExpression memberExpres } } - throw new ArgumentException( - $"The expression '{selector}' does not originate from parameter '{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) + private static ResourceFieldChainExpression ResolveSingleFieldChain(List members, ResourceType resourceType, Expression selector) { List fields = []; IAttributeContainer currentContainer = resourceType; @@ -309,8 +307,10 @@ private static ResourceFieldChainExpression ResolveSingleFieldChain( if (field == null) { string containerName = currentContainer is ResourceType resType ? resType.PublicName : currentContainer.ClrType.Name; + throw new ArgumentException( - $"Member '{member.Name}' is not exposed as a JSON:API field on '{containerName}'.", nameof(selector)); + $"The expression '{selector}' contains member '{member.Name}', which is not exposed as a JSON:API field on '{containerName}'.", + nameof(selector)); } fields.Add(field); @@ -328,7 +328,7 @@ private static ResourceFieldChainExpression ResolveSingleFieldChain( else { throw new ArgumentException( - $"Member '{field.Property.Name}' cannot have nested fields because it is neither a compound attribute nor a relationship.", + $"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)); } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 6cee230583..c0b6461c7c 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -346,6 +346,7 @@ private Dictionary.ValueCollection GetAttributes(Type res SetPublicName(attribute, property); attribute.Property = property; + attribute.IsCollection = property.PropertyType != typeof(string) && CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null; @@ -448,6 +449,7 @@ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Qu { SetPublicName(attribute, property); attribute.Property = property; + attribute.IsCollection = property.PropertyType != typeof(string) && CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index d017ea7d31..99c0e39521 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -27,8 +27,7 @@ public static class SparseFieldSetExpressionExtensions return newSparseFieldSet; } - public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, - ResourceFieldChainExpression fieldChain) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) { ArgumentNullException.ThrowIfNull(fieldChain); @@ -68,8 +67,7 @@ public static class SparseFieldSetExpressionExtensions return newSparseFieldSet; } - public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, - ResourceFieldChainExpression fieldChain) + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) { ArgumentNullException.ThrowIfNull(fieldChain); @@ -94,8 +92,7 @@ public static class SparseFieldSetExpressionExtensions } ImmutableHashSet newFieldChains = sparseFieldSet.FieldChains - .Where(chain => !IsPrefixOrEqual(chainToExclude, chain)) - .ToImmutableHashSet(); + .Where(chain => !IsPrefixOrEqual(chainToExclude, chain)).ToImmutableHashSet(); if (newFieldChains.Count == sparseFieldSet.FieldChains.Count) { diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index abc58f94e6..2fad6b74aa 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -511,7 +511,9 @@ protected virtual AnyExpression ParseAny() if (nextToken.Value == Keywords.Element) { EatText(Keywords.Element); - return (new CollectionElementValueExpression(ScalarCollectionElementInScope), () => GetConstantValueConverterForType(ScalarCollectionElementInScope)); + + return (new CollectionElementValueExpression(ScalarCollectionElementInScope), + () => GetConstantValueConverterForType(ScalarCollectionElementInScope)); } throw new QueryParseException("Function or '_' expected.", nextToken.Position); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index 7052f7d031..c0af1f7ddb 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -153,7 +153,6 @@ protected virtual Expression ApplySelection(Expression source, FieldSelection se 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)); + 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 385ddc7fbc..a92a9450f4 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -278,8 +278,7 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Membe // 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) || + 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; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 676a0aece2..b9c3dfaea7 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -79,6 +79,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje 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); @@ -111,8 +112,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje state.WritableTargetedFields!.Attributes.Add(attr); } - private object? ConvertCompoundObject(JsonElement element, CompoundType compoundType, Type targetType, string? memberName, - RequestAdapterState state) + private object? ConvertCompoundObject(JsonElement element, CompoundType compoundType, Type targetType, string? memberName, RequestAdapterState state) { if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { @@ -122,6 +122,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje 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}'."; @@ -197,6 +198,7 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje 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}'."; @@ -204,9 +206,8 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje throw new ModelConversionException(state.Position, "Incompatible attribute value found.", message); } - Type elementType = CollectionConverter.Instance.FindCollectionElementType(collectionType) - ?? (collectionType.IsArray ? collectionType.GetElementType() : null) - ?? elementCompoundType.ClrType; + Type elementType = CollectionConverter.Instance.FindCollectionElementType(collectionType) ?? + (collectionType.IsArray ? collectionType.GetElementType() : null) ?? elementCompoundType.ClrType; var items = new List(); int index = 0; @@ -227,7 +228,8 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje if (collectionType.IsArray) { - Array array = Array.CreateInstance(elementType, items.Count); + var array = Array.CreateInstance(elementType, items.Count); + for (int i = 0; i < items.Count; i++) { array.SetValue(items[i], i); @@ -243,8 +245,7 @@ private static object CreateCompoundInstance(Type type) { try { - return Activator.CreateInstance(type, nonPublic: true) - ?? throw new InvalidOperationException($"Failed to instantiate compound type '{type.Name}'."); + return Activator.CreateInstance(type, true) ?? throw new InvalidOperationException($"Failed to instantiate compound type '{type.Name}'."); } catch (MissingMethodException exception) { @@ -258,6 +259,7 @@ private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string 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 {containerKind} '{container}'."); } @@ -285,6 +287,7 @@ private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute a 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 {containerKind} '{container}' cannot be assigned to."); } @@ -295,6 +298,7 @@ private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute a 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 {containerKind} '{container}' cannot be assigned to."); } @@ -305,8 +309,7 @@ private static void AssertNotReadOnly(AttrAttribute attr, IAttributeContainer co if (attr.Property.SetMethod == null) { 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."); + 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 0a9b86e481..b74642f37f 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -302,8 +302,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return attrMap.Count > 0 ? attrMap : null; } - private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, - CompoundSelectionContext context) + private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, CompoundSelectionContext context) { var prunedMap = new Dictionary(); @@ -360,24 +359,6 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return prunedMap; } - private readonly struct CompoundSelectionContext - { - public HashSet? DirectFields { get; } - public Dictionary>? DeeperChainsByAttribute { get; } - - public CompoundSelectionContext(IReadOnlyList? fieldChains) - { - if (fieldChains is { Count: > 0 }) - { - DirectFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); - DeeperChainsByAttribute = fieldChains - .Where(c => c.Fields.Count > 1) - .GroupBy(c => c.Fields[0]) - .ToDictionary(g => g.Key, g => g.Select(c => new ResourceFieldChainExpression(c.Fields.RemoveAt(0))).ToList()); - } - } - } - private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, EndpointKind kind) { @@ -515,4 +496,21 @@ private static SingleOrManyData GetRelationshipData(Re return !hasErrors && _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; } + + private readonly struct CompoundSelectionContext + { + public HashSet? DirectFields { get; } + public Dictionary>? DeeperChainsByAttribute { get; } + + public CompoundSelectionContext(IReadOnlyList? fieldChains) + { + if (fieldChains is { Count: > 0 }) + { + DirectFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); + + DeeperChainsByAttribute = fieldChains.Where(c => c.Fields.Count > 1).GroupBy(c => c.Fields[0]).ToDictionary(g => g.Key, + g => g.Select(c => new ResourceFieldChainExpression(c.Fields.RemoveAt(0))).ToList()); + } + } + } } 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/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index bf0e1945e1..511923ce0b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -41,7 +41,11 @@ public CompoundAttributesFakers() .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)])); + .RuleFor(company => company.BusinessHours, _ => + [ + TimeSpan.FromHours(9), + TimeSpan.FromHours(17) + ])); _lazyContactAddressFaker = new Lazy>(() => new Faker() .MakeDeterministic() @@ -69,7 +73,12 @@ public CompoundAttributesFakers() .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.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 => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index 3d83d38e01..ba217e3fe8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -48,10 +48,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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"]; + object? contentValue = responseDocument.Data.SingleValue.Attributes["content"]; contentValue.Should().BeOfType(); - var contentElement = (JsonElement)contentValue!; + 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); @@ -478,8 +478,18 @@ 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"]; + + addressBooks[0].Categories = + [ + "Personal", + "Work" + ]; + + addressBooks[1].Categories = + [ + "Business", + "Archive" + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -504,7 +514,13 @@ public async Task Can_filter_categories_using_contains() { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].Categories = ["Personal", "Networking"]; + + addressBooks[0].Categories = + [ + "Personal", + "Networking" + ]; + addressBooks[1].Categories = ["Family"]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -530,8 +546,14 @@ public async Task Can_filter_syncDeviceIds_by_has_guid_equality() { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - Guid targetGuid = Guid.NewGuid(); - addressBooks[0].SyncDeviceIds = [targetGuid, Guid.NewGuid()]; + var targetGuid = Guid.NewGuid(); + + addressBooks[0].SyncDeviceIds = + [ + targetGuid, + Guid.NewGuid() + ]; + addressBooks[1].SyncDeviceIds = [Guid.NewGuid()]; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -557,7 +579,13 @@ public async Task Can_filter_syncDeviceIds_by_count() { // Arrange List addressBooks = _fakers.AddressBook.GenerateList(2); - addressBooks[0].SyncDeviceIds = [Guid.NewGuid(), Guid.NewGuid()]; + + addressBooks[0].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; + addressBooks[1].SyncDeviceIds = []; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -584,8 +612,19 @@ 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()]; + + addressBooks[1].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + ]; + + addressBooks[2].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -613,8 +652,19 @@ 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()]; + + addressBooks[1].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + ]; + + addressBooks[2].SyncDeviceIds = + [ + Guid.NewGuid(), + Guid.NewGuid() + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -641,15 +691,25 @@ 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)] + BusinessHours = + [ + TimeSpan.FromHours(9), + TimeSpan.FromHours(17) + ] }; + contacts[1].Content.Company = new ContactCompany { Name = "Company B", - BusinessHours = [TimeSpan.FromHours(8), TimeSpan.FromHours(16)] + BusinessHours = + [ + TimeSpan.FromHours(8), + TimeSpan.FromHours(16) + ] }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -675,8 +735,20 @@ 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]; + + contacts[0].Content.Ratings = + [ + 5, + null, + 4 + ]; + + contacts[1].Content.Ratings = + [ + 1, + 2, + 3 + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -701,6 +773,7 @@ public async Task Can_filter_importantDates_by_value() { // Arrange List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.ImportantDates = [ new LabeledValue @@ -709,6 +782,7 @@ public async Task Can_filter_importantDates_by_value() Label = "anniversary" } ]; + contacts[1].Content.ImportantDates = [ new LabeledValue @@ -741,6 +815,7 @@ public async Task Can_filter_importantDates_by_label() { // Arrange List contacts = _fakers.Contact.GenerateList(2); + contacts[0].Content.ImportantDates = [ new LabeledValue @@ -749,6 +824,7 @@ public async Task Can_filter_importantDates_by_label() Label = "anniversary" } ]; + contacts[1].Content.ImportantDates = [ new LabeledValue diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs index 5b4fa8eb22..e5ff5d236e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -286,6 +286,7 @@ public async Task Can_select_properties_across_different_compound_branches() { // Arrange Contact contact = _fakers.Contact.Generate(); + contact.Content.Company = new ContactCompany { Name = "Acme Corp", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 6c350ed028..3e4f2aca36 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -269,7 +269,7 @@ public async Task Cannot_create_contact_with_empty_required_collection_element_p { new { - number = "", + number = string.Empty, label = "work" } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs index 476dc0ddd0..75551d1886 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesWriteTests.cs @@ -619,18 +619,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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 - }, + 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, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs index 8ce2b58382..59073e855f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs @@ -110,7 +110,13 @@ public sealed class ContactName // Calculated read-only property (get-only; no setter) [Attr] - public string DisplayName => string.Join(' ', new[] { FirstName, MiddleName, LastName }.Where(s => !string.IsNullOrEmpty(s))); + 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)] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs index 8ed80703bf..abbe63edb1 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs @@ -17,9 +17,7 @@ public sealed class SparseFieldSetExpressionExtensionsTests public SparseFieldSetExpressionExtensionsTests() { var options = new JsonApiOptions(); - _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Build(); + _resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); } [Fact] @@ -41,7 +39,7 @@ public void Including_preserves_nested_field_chains() // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(2); + updatedSet.FieldChains.Should().HaveCount(2); updatedSet.FieldChains.Should().Contain(nestedChain); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); updatedSet.FieldChains.Should().Contain(new ResourceFieldChainExpression(remarksAttr)); @@ -69,7 +67,7 @@ public void Excluding_removes_matching_field_and_preserves_nested_chains() // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(1); + updatedSet.FieldChains.Should().HaveCount(1); updatedSet.FieldChains.Should().Contain(nestedChain); updatedSet.ToString().Should().Be("content.name.lastName"); } @@ -95,7 +93,7 @@ public void Excluding_top_level_compound_removes_all_its_nested_chains() // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(1); + updatedSet.FieldChains.Should().HaveCount(1); updatedSet.FieldChains.Should().Contain(remarksChain); updatedSet.ToString().Should().Be("remarks"); } @@ -113,7 +111,7 @@ public void Including_accepts_nested_lambda_chain_and_preserves_it() // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(2); + updatedSet.FieldChains.Should().HaveCount(2); updatedSet.ToString().Should().Be("content.name.lastName,remarks"); } @@ -123,7 +121,8 @@ public void Excluding_accepts_nested_lambda_chain() // Arrange ResourceType contactType = _resourceGraph.GetResourceType(); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); - var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))) + + SparseFieldSetExpression? initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))) .Including(c => c.Content!.Name!.LastName, _resourceGraph); // Act @@ -131,7 +130,7 @@ public void Excluding_accepts_nested_lambda_chain() // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(1); + updatedSet.FieldChains.Should().HaveCount(1); updatedSet.ToString().Should().Be("remarks"); } @@ -144,11 +143,15 @@ public void Including_accepts_anonymous_type_with_nested_and_top_level_lambdas() var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))); // Act - SparseFieldSetExpression? updatedSet = initialSet.Including(c => new { c.Remarks, c.Content!.Name!.LastName }, _resourceGraph); + SparseFieldSetExpression? updatedSet = initialSet.Including(c => new + { + c.Remarks, + c.Content!.Name!.LastName + }, _resourceGraph); // Assert updatedSet.Should().NotBeNull(); - updatedSet!.FieldChains.Should().HaveCount(2); + updatedSet.FieldChains.Should().HaveCount(2); updatedSet.ToString().Should().Be("content.name.lastName,remarks"); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs index ebe4b66aa5..d06cecdf2c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -65,11 +65,11 @@ public void Can_lookup_compound_type_in_resource_graph() // Act & Assert resourceGraph.GetCompoundTypes().Should().ContainSingle().Which.Should().Be(compoundType); - resourceGraph.FindCompoundType(typeof(TestAddress)).Should().Be(compoundType); - resourceGraph.FindCompoundType(typeof(string)).Should().BeNull(); + resourceGraph.FindCompoundType().Should().Be(compoundType); + resourceGraph.FindCompoundType().Should().BeNull(); - resourceGraph.GetCompoundType(typeof(TestAddress)).Should().Be(compoundType); - Action getUnknown = () => resourceGraph.GetCompoundType(typeof(string)); + resourceGraph.GetCompoundType().Should().Be(compoundType); + Action getUnknown = () => resourceGraph.GetCompoundType(); getUnknown.Should().ThrowExactly().WithMessage("Compound type 'System.String' does not exist in the resource graph."); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index 825d133ff7..ddb149f2a8 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using FluentAssertions; +using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -251,7 +252,11 @@ public void Evaluates_IsCollection_correctly() listAttr.IsCollection.Should().BeTrue(); setAttr.IsCollection.Should().BeTrue(); - var customAttr = new AttrAttribute { IsCollection = true }; + var customAttr = new AttrAttribute + { + IsCollection = true + }; + customAttr.IsCollection.Should().BeTrue(); } @@ -272,6 +277,7 @@ public void Can_set_and_get_IsCompound_and_CompoundType() attribute.CompoundType.Should().Be(compoundType); } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class TestResourceWithCollections : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 638ce5ab4e..c82bf219d1 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -487,9 +487,9 @@ public void Discovers_compound_types_recursively() phonesAttr.CompoundType!.ClrType.Should().Be(); resourceGraph.GetCompoundTypes().Should().HaveCount(3); - resourceGraph.GetCompoundType(typeof(TestRootCompound)).Should().Be(rootCompoundType); - resourceGraph.GetCompoundType(typeof(TestChildCompound)).Should().Be(nameCompoundType); - resourceGraph.GetCompoundType(typeof(TestPhoneCompound)).Should().Be(phonesAttr.CompoundType); + resourceGraph.GetCompoundType().Should().Be(rootCompoundType); + resourceGraph.GetCompoundType().Should().Be(nameCompoundType); + resourceGraph.GetCompoundType().Should().Be(phonesAttr.CompoundType); } [Fact] @@ -526,18 +526,26 @@ public void GetFieldChain_and_GetFieldChains_resolve_nested_compound_chains_and_ // Act ResourceFieldChainExpression singleChain = resourceGraph.GetFieldChain(x => x.Content!.Name!.FirstName); - IReadOnlyCollection multiChains = resourceGraph.GetFieldChains( - x => new { x.Content!.Name!.FirstName, x.Content.PhoneNumbers }); + + 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"]); + 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 + }); - Action multipleInSingleAction = () => resourceGraph.GetFieldChain( - x => new { x.Content!.Name!.FirstName, x.Content.PhoneNumbers }); multipleInSingleAction.Should().ThrowExactly(); Action invalidPropertyAction = () => resourceGraph.GetFieldChain(x => x.Id.ToString()); @@ -702,8 +710,8 @@ public void Supports_empty_base_class_in_compound_type_hierarchy() IResourceGraph resourceGraph = builder.Build(); // Assert - resourceGraph.FindCompoundType(typeof(DerivedCompound)).Should().NotBeNull(); - CompoundType compoundType = resourceGraph.GetCompoundType(typeof(DerivedCompound)); + resourceGraph.FindCompoundType().Should().NotBeNull(); + CompoundType compoundType = resourceGraph.GetCompoundType(); compoundType.Attributes.Should().ContainSingle(attr => attr.Property.Name == nameof(DerivedCompound.Value)); } From df247345baf603ac76a0950c6af15e423f62dbb0 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:10:40 +0200 Subject: [PATCH 47/63] Fix ToString/Equals on AttrAttribute --- .../Configuration/CompoundType.cs | 7 ------- .../Resources/Annotations/AttrAttribute.cs | 18 +++++++----------- test/AnnotationTests/Models/TreeNode.cs | 2 +- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index 94df710c05..560d2b7654 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -7,15 +7,8 @@ namespace JsonApiDotNetCore.Configuration; /// Metadata about the shape of an embedded compound value object in the resource graph. /// /// -/// /// Compound types used with sparse fieldsets or request deserialization must provide an accessible parameterless constructor (can be non-public) to /// support member initialization expressions and object instantiation. -/// -/// -/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API resource and compound models and are -/// never taken into account when reading request bodies, rendering responses, or generating error source pointers. Schema names and capabilities are -/// governed exclusively by [Attr(PublicName)] and [Attr(Capabilities)]. -/// /// [PublicAPI] public sealed class CompoundType : IAttributeContainer diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index e44bb8811d..21a22ec398 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -8,11 +8,6 @@ namespace JsonApiDotNetCore.Resources.Annotations; /// /// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). /// -/// -/// Note: System.Text.Json attributes (such as [JsonPropertyName] and [JsonIgnore]) are not supported on JSON:API resource and compound models and are -/// never taken into account when reading request bodies, rendering responses, or generating error source pointers. Schema names and capabilities are -/// governed exclusively by [Attr(PublicName)] and [Attr(Capabilities)]. -/// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] public sealed class AttrAttribute : ResourceFieldAttribute @@ -40,14 +35,14 @@ public AttrCapabilities Capabilities } /// - /// Indicates whether this attribute is a complex/compound object. Defaults to false. + /// Indicates whether the underlying property is a collection. /// - public bool IsCompound { get; set; } + public bool IsCollection { get; internal set; } /// - /// Indicates whether the underlying property is a collection. + /// Indicates whether this attribute represents a compound object. Defaults to false. /// - public bool IsCollection { get; internal set; } + public bool IsCompound { get; set; } /// /// The schema of the nested compound type when is true; otherwise null. @@ -69,12 +64,13 @@ public override bool Equals(object? obj) var other = (AttrAttribute)obj; - return Capabilities == other.Capabilities && IsCompound == other.IsCompound && CompoundType == other.CompoundType && 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, IsCompound, CompoundType, base.GetHashCode()); + return HashCode.Combine(Capabilities, IsCollection, IsCompound, CompoundType, base.GetHashCode()); } } 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)] From 7bea8bd3f8b56f94a3d35dbcdc01473ea3b3c9cf Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:38:50 +0200 Subject: [PATCH 48/63] Rename built-in patterns, remove unused ones --- .../Queries/Parsing/FilterParser.cs | 10 +++++----- .../Queries/Parsing/SortParser.cs | 2 +- .../Queries/Parsing/SparseFieldSetParser.cs | 4 ++-- .../QueryStrings/FieldChains/BuiltInPatterns.cs | 15 ++++----------- .../Decrypt/DecryptFilterParser.cs | 4 ++-- .../IsUpperCase/IsUpperCaseFilterParser.cs | 4 ++-- .../StringLength/LengthFilterParser.cs | 4 ++-- .../StringLength/LengthSortParser.cs | 2 +- .../CustomFunctions/Sum/SumFilterParser.cs | 2 +- 9 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 2fad6b74aa..75dcb73878 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -287,8 +287,8 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe // Allow equality comparison of a to-one relationship or compound attribute with null. FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals - ? BuiltInPatterns.ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound - : BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute; + ? BuiltInPatterns.ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne + : BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute; return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ContainerInScope, "Function or field name expected."); } @@ -379,7 +379,7 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal throw new QueryParseException(errorMessage, nextToken.Position); } - return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, errorMessage); } } @@ -450,7 +450,7 @@ private QueryExpression ParseTextMatchLeftTerm() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -526,7 +526,7 @@ protected virtual AnyExpression ParseAny() throw new QueryParseException("Function or '_' expected.", position); } - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index b6553f8d83..e01bedba5d 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -94,7 +94,7 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy { string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; - target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, + target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index 64e68f6259..d5fdf25aae 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -38,8 +38,8 @@ public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.SparseFieldSetChain, FieldChainPatternMatchOptions.None, resourceType, - "Field name expected."); + ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.CompoundOrCompoundCollectionChainEndingInField, + FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); fieldSetBuilder.Add(nextChain); } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index bf7818094f..17e64dcb9c 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -5,17 +5,10 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; [PublicAPI] public static class BuiltInPatterns { - public static FieldChainPattern SingleField { get; } = FieldChainPattern.Parse("F"); public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); - public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A"); - public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); - public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); - - public static FieldChainPattern ToOneOrCompoundChainEndingInAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); - public static FieldChainPattern ToOneOrCompoundChainEndingInAttributeOrToOneOrCompound { get; } = FieldChainPattern.Parse("[OC]*[AOC]"); + public static FieldChainPattern ToOneChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("O*A"); + public static FieldChainPattern ToOneOrCompoundChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); + public static FieldChainPattern ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne { get; } = FieldChainPattern.Parse("[OC]*[ACO]"); public static FieldChainPattern ToOneOrCompoundChainEndingInCollection { get; } = FieldChainPattern.Parse("[OC]*[MLQ]"); - public static FieldChainPattern SparseFieldSetChain { get; } = FieldChainPattern.Parse("[CQ]*F"); - - [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 CompoundOrCompoundCollectionChainEndingInField { get; } = FieldChainPattern.Parse("[CQ]*F"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs index 7fbc3ed77b..3a77948ac6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs @@ -36,8 +36,8 @@ private DecryptExpression ParseDecrypt() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index 99c5b30bc2..53b4bad22c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -26,8 +26,8 @@ private IsUpperCaseExpression ParseIsUpperCase() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index f787319373..bccdfef660 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -36,8 +36,8 @@ private LengthExpression ParseLength() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs index 6b24d4d4bc..ea01c4b49c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -35,7 +35,7 @@ private LengthExpression ParseLength(ResourceType resourceType) int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index cb33eaf030..70828dcf4d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -73,7 +73,7 @@ private QueryExpression ParseSumSelector() return function; } - ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; From 3a58e6636b5589872d273956b57b28be3bb6be34 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:11:51 +0200 Subject: [PATCH 49/63] Remove redundant overloads --- .../Configuration/ResourceGraph.cs | 5 ---- .../ResourceFieldChainExpression.cs | 4 +-- .../Queries/Parsing/QueryExpressionParser.cs | 9 ------ .../FieldChains/FieldChainPattern.cs | 28 ++----------------- 4 files changed, 3 insertions(+), 43 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 3ae7e74135..f61bc028e4 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -23,11 +23,6 @@ public sealed class ResourceGraph : IResourceGraph private readonly IReadOnlySet _compoundTypeSet; private readonly Dictionary _compoundTypesByClrType = []; - public ResourceGraph(IReadOnlySet resourceTypeSet) - : this(resourceTypeSet, EmptyCompoundTypeSet) - { - } - public ResourceGraph(IReadOnlySet resourceTypeSet, IReadOnlySet compoundTypeSet) { ArgumentNullException.ThrowIfNull(resourceTypeSet); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 3d92471192..a34872dc6b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -20,9 +20,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class ResourceFieldChainExpression : IdentifierExpression { /// - /// A list of one or more JSON:API fields. Use - /// - /// to convert from text. + /// A list of one or more JSON:API fields. Use to convert from text. /// public IImmutableList Fields { get; } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index 0c572e9e84..ac2e06a464 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -69,15 +69,6 @@ private static Stack TokensToStack(Token[] tokens) return stack; } - /// - /// 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) - { - return ParseFieldChain(pattern, options, (IAttributeContainer)resourceType, alternativeErrorMessage); - } - /// /// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern. /// diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index 0e168fa15d..29712f2b37 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -150,30 +150,6 @@ public static FieldChainPattern Parse(string pattern) return parser.Parse(pattern); } - /// - /// Matches the specified resource field chain against this pattern. - /// - /// - /// The dot-separated chain of resource field names. - /// - /// - /// The parent resource type to start matching from. - /// - /// - /// Match options, defaults to . - /// - /// - /// When provided, logs the matching steps at level. - /// - /// - /// The match result. - /// - public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, - ILoggerFactory? loggerFactory = null) - { - return Match(fieldChain, (IAttributeContainer)resourceType, options, loggerFactory); - } - /// /// Matches the specified resource field chain against this pattern, starting from the specified container. /// @@ -192,8 +168,8 @@ public PatternMatchResult Match(string fieldChain, ResourceType resourceType, Fi /// /// The match result. /// - public PatternMatchResult Match(string fieldChain, IAttributeContainer container, - FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, ILoggerFactory? loggerFactory = null) + public PatternMatchResult Match(string fieldChain, IAttributeContainer container, FieldChainPatternMatchOptions options, + ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(fieldChain); ArgumentNullException.ThrowIfNull(container); From 636663e9a155c0b9e8e0c31ced5afd7e28cdd051 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:32:52 +0200 Subject: [PATCH 50/63] Cleanup FilterParser somewhat --- .../CollectionElementValueExpression.cs | 6 +- .../Queries/Parsing/FilterParser.cs | 99 ++++++++++--------- .../Queries/Parsing/Keywords.cs | 1 - .../CompoundFilterParseTests.cs | 4 +- 4 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs index 37a4fceaf9..ed1378d60b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs @@ -8,6 +8,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public sealed class CollectionElementValueExpression : IdentifierExpression { + internal const string Symbol = "_"; + /// /// The CLR type of the collection element (e.g. , ). /// @@ -26,12 +28,12 @@ public override TResult Accept(QueryExpressionVisitor FilterKeywords = [ Keywords.Not, @@ -35,12 +37,12 @@ public class FilterParser : QueryExpressionParser, IFilterParser private readonly IResourceFactory _resourceFactory; private readonly Stack _containerStack = new(); - private readonly Stack _scalarCollectionElementStack = new(); + private readonly Stack _scalarCollectionElementTypeStack = new(); /// /// Gets the CLR type of the scalar collection element currently in scope, or null if not in scope of a scalar collection. /// - protected Type? ScalarCollectionElementInScope => _scalarCollectionElementStack.Count > 0 ? _scalarCollectionElementStack.Peek() : null; + protected Type? ScalarCollectionElementTypeInScope => _scalarCollectionElementTypeStack.Count > 0 ? _scalarCollectionElementTypeStack.Peek() : null; /// /// Gets the container currently in scope. Call to temporarily change the current container. @@ -89,7 +91,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); _containerStack.Clear(); - _scalarCollectionElementStack.Clear(); + _scalarCollectionElementTypeStack.Clear(); FilterExpression expression; using (InScopeOfResourceType(resourceType)) @@ -100,7 +102,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) } AssertContainerStackIsEmpty(); - AssertScalarCollectionElementStackIsEmpty(); + AssertScalarCollectionElementTypeStackIsEmpty(); return expression; } @@ -130,7 +132,7 @@ protected virtual FunctionExpression ParseFunction() private CountExpression ParseCount() { - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'count' cannot be used on scalar collection elements.", position); @@ -267,22 +269,22 @@ private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOpe return ParseFunction(); } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { - if (nextToken.Value == Keywords.Element) + if (nextToken.Value == CollectionElementSymbol) { - EatText(Keywords.Element); - return new CollectionElementValueExpression(ScalarCollectionElementInScope); + EatText(CollectionElementSymbol); + return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); } - throw new QueryParseException("Function or '_' expected.", nextToken.Position); + throw new QueryParseException("Function or _ expected.", nextToken.Position); } } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or '_' expected.", position); + throw new QueryParseException("Function or _ expected.", position); } // Allow equality comparison of a to-one relationship or compound attribute with null. @@ -332,11 +334,11 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal // @formatter:nested_ternary_style expanded string errorMessage = allowNull - ? ScalarCollectionElementInScope != null - ? "Function, value between quotes, '_' or null expected." + ? ScalarCollectionElementTypeInScope != null + ? "Function, value between quotes, _ or null expected." : "Function, field name, value between quotes or null expected." - : ScalarCollectionElementInScope != null - ? "Function, value between quotes or '_' expected." + : ScalarCollectionElementTypeInScope != null + ? "Function, value between quotes or _ expected." : "Function, field name or value between quotes expected."; // @formatter:nested_ternary_style restore @@ -368,12 +370,12 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { - if (nextToken.Value == Keywords.Element) + if (nextToken.Value == CollectionElementSymbol) { - EatText(Keywords.Element); - return new CollectionElementValueExpression(ScalarCollectionElementInScope); + EatText(CollectionElementSymbol); + return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); } throw new QueryParseException(errorMessage, nextToken.Position); @@ -424,28 +426,28 @@ private QueryExpression ParseTextMatchLeftTerm() return targetFunction; } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { - if (nextToken.Value == Keywords.Element) + if (nextToken.Value == CollectionElementSymbol) { - EatText(Keywords.Element); + EatText(CollectionElementSymbol); - if (ScalarCollectionElementInScope != typeof(string)) + if (ScalarCollectionElementTypeInScope != typeof(string)) { throw new QueryParseException("Element of type 'String' expected.", nextToken.Position); } - return new CollectionElementValueExpression(ScalarCollectionElementInScope); + return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); } - throw new QueryParseException("Function or '_' expected.", nextToken.Position); + throw new QueryParseException("Function or _ expected.", nextToken.Position); } } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or '_' expected.", position); + throw new QueryParseException("Function or _ expected.", position); } int chainStartPosition = GetNextTokenPositionOrEnd(); @@ -506,24 +508,25 @@ protected virtual AnyExpression ParseAny() return (targetFunction, functionConverterFactory); } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { - if (nextToken.Value == Keywords.Element) + if (nextToken.Value == CollectionElementSymbol) { - EatText(Keywords.Element); + EatText(CollectionElementSymbol); + var targetExpression = new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); - return (new CollectionElementValueExpression(ScalarCollectionElementInScope), - () => GetConstantValueConverterForType(ScalarCollectionElementInScope)); + Func functionConverterFactory = () => GetConstantValueConverterForType(ScalarCollectionElementTypeInScope); + return (targetExpression, functionConverterFactory); } - throw new QueryParseException("Function or '_' expected.", nextToken.Position); + throw new QueryParseException("Function or _ expected.", nextToken.Position); } } - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or '_' expected.", position); + throw new QueryParseException("Function or _ expected.", position); } ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, @@ -537,7 +540,7 @@ protected virtual AnyExpression ParseAny() protected virtual HasExpression ParseHas() { - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'has' cannot be used on scalar collection elements.", position); @@ -577,7 +580,7 @@ protected virtual HasExpression ParseHas() throw new InvalidOperationException( $"Cannot determine element type for scalar collection attribute '{scalarCollectionAttr.Property.Name}'."); - using (InScopeOfScalarCollectionElement(elementType)) + using (InScopeOfScalarCollectionElementType(elementType)) { filter = ParseFilter(); } @@ -591,7 +594,7 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { - if (ScalarCollectionElementInScope != null) + if (ScalarCollectionElementTypeInScope != null) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); @@ -795,12 +798,12 @@ protected IDisposable InScopeOfContainer(IAttributeContainer container) /// /// Changes the scalar collection element type currently in scope and restores the original type when the return value is disposed. /// - protected IDisposable InScopeOfScalarCollectionElement(Type elementType) + protected IDisposable InScopeOfScalarCollectionElementType(Type elementType) { ArgumentNullException.ThrowIfNull(elementType); - _scalarCollectionElementStack.Push(elementType); - return new PopScalarCollectionElementOnDispose(_scalarCollectionElementStack); + _scalarCollectionElementTypeStack.Push(elementType); + return new PopScalarCollectionElementTypeOnDispose(_scalarCollectionElementTypeStack); } private void AssertContainerStackIsEmpty() @@ -812,12 +815,12 @@ private void AssertContainerStackIsEmpty() } } - private void AssertScalarCollectionElementStackIsEmpty() + private void AssertScalarCollectionElementTypeStackIsEmpty() { - if (_scalarCollectionElementStack.Count > 0) + if (_scalarCollectionElementTypeStack.Count > 0) { 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(InScopeOfScalarCollectionElement)}()."); + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfScalarCollectionElementType)}()."); } } @@ -831,13 +834,13 @@ public void Dispose() } } - private sealed class PopScalarCollectionElementOnDispose(Stack scalarCollectionElementStack) : IDisposable + private sealed class PopScalarCollectionElementTypeOnDispose(Stack scalarCollectionElementTypeStack) : IDisposable { - private readonly Stack _scalarCollectionElementStack = scalarCollectionElementStack; + private readonly Stack _scalarCollectionElementTypeStack = scalarCollectionElementTypeStack; public void Dispose() { - _scalarCollectionElementStack.Pop(); + _scalarCollectionElementTypeStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs index 5eed05c293..f39fcf8227 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs @@ -21,5 +21,4 @@ public static class Keywords public const string Count = "count"; public const string Has = "has"; public const string IsType = "isType"; - public const string Element = "_"; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index 25dee2882d..3a62933d18 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -83,8 +83,8 @@ public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string pa "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] - [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or '_' expected.")] - [InlineData("filter", "has(tags,equals(^unknown,'work'))", "Function or '_' expected.")] + [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or _ expected.")] + [InlineData("filter", "has(tags,equals(^unknown,'work'))", "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' cannot be used on scalar collection elements.")] From 2dc66a9470e94258a09678b382a40d05a9d90f08 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:54:23 +0200 Subject: [PATCH 51/63] Fix enum member names on FieldTypes --- .../FieldChains/FieldTypeExtensions.cs | 4 ++-- .../QueryStrings/FieldChains/FieldTypes.cs | 15 ++++++++------- .../QueryStrings/FieldChains/PatternMatcher.cs | 4 ++-- .../QueryStrings/FieldChains/PatternParser.cs | 12 ++++++------ .../FieldChains/PatternTextFormatter.cs | 4 ++-- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs index 29d0e300b1..1cb95896b8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -39,12 +39,12 @@ public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool WriteChoice("compound attribute", pluralize, prefix, false, builder, startOffset); } - if (choices.HasFlag(FieldTypes.ScalarCollection)) + if (choices.HasFlag(FieldTypes.ScalarCollectionAttribute)) { WriteChoice("scalar collection", pluralize, prefix, false, builder, startOffset); } - if (choices.HasFlag(FieldTypes.CompoundCollection)) + if (choices.HasFlag(FieldTypes.CompoundCollectionAttribute)) { WriteChoice("compound collection", pluralize, prefix, false, builder, startOffset); } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs index d9f1f43872..d4105f6e6b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -4,13 +4,14 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; internal enum FieldTypes { None = 0, - ScalarAttribute = 1, - ToOneRelationship = 1 << 1, - ToManyRelationship = 1 << 2, - CompoundAttribute = 1 << 3, - ScalarCollection = 1 << 4, - CompoundCollection = 1 << 5, + ToOneRelationship = 1, + ToManyRelationship = 1 << 1, + ScalarAttribute = 1 << 2, + ScalarCollectionAttribute = 1 << 3, + CompoundAttribute = 1 << 4, + CompoundCollectionAttribute = 1 << 5, Relationship = ToOneRelationship | ToManyRelationship, - Field = ScalarAttribute | Relationship | CompoundAttribute | ScalarCollection | CompoundCollection + Attribute = ScalarAttribute | ScalarCollectionAttribute | CompoundAttribute | CompoundCollectionAttribute, + Field = Relationship | Attribute } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs index 23ec457cee..65fbe62d60 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -242,9 +242,9 @@ private static FieldTypes GetFieldType(ResourceFieldAttribute? field) HasManyAttribute => FieldTypes.ToManyRelationship, HasOneAttribute => FieldTypes.ToOneRelationship, RelationshipAttribute => FieldTypes.Relationship, - AttrAttribute { IsCompound: true, IsCollection: true } => FieldTypes.CompoundCollection, + AttrAttribute { IsCompound: true, IsCollection: true } => FieldTypes.CompoundCollectionAttribute, AttrAttribute { IsCompound: true, IsCollection: false } => FieldTypes.CompoundAttribute, - AttrAttribute { IsCompound: false, IsCollection: true } => FieldTypes.ScalarCollection, + 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 fab51cbee6..735cbabe94 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -16,9 +16,9 @@ internal sealed class PatternParser ['O'] = Token.ToOneRelationship, ['R'] = Token.Relationship, ['A'] = Token.ScalarAttribute, + ['L'] = Token.ScalarCollectionAttribute, ['C'] = Token.CompoundAttribute, - ['L'] = Token.ScalarCollection, - ['Q'] = Token.CompoundCollection, + ['Q'] = Token.CompoundCollectionAttribute, ['F'] = Token.Field }; @@ -29,8 +29,8 @@ internal sealed class PatternParser [Token.Relationship] = FieldTypes.Relationship, [Token.ScalarAttribute] = FieldTypes.ScalarAttribute, [Token.CompoundAttribute] = FieldTypes.CompoundAttribute, - [Token.ScalarCollection] = FieldTypes.ScalarCollection, - [Token.CompoundCollection] = FieldTypes.CompoundCollection, + [Token.ScalarCollectionAttribute] = FieldTypes.ScalarCollectionAttribute, + [Token.CompoundCollectionAttribute] = FieldTypes.CompoundCollectionAttribute, [Token.Field] = FieldTypes.Field }; @@ -188,8 +188,8 @@ private enum Token Relationship, ScalarAttribute, CompoundAttribute, - ScalarCollection, - CompoundCollection, + ScalarCollectionAttribute, + CompoundCollectionAttribute, Field } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs index ba49443f9a..94932bb45c 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -68,12 +68,12 @@ private static void WriteChoices(FieldTypes types, StringBuilder builder) builder.Append('C'); } - if (types.HasFlag(FieldTypes.ScalarCollection)) + if (types.HasFlag(FieldTypes.ScalarCollectionAttribute)) { builder.Append('L'); } - if (types.HasFlag(FieldTypes.CompoundCollection)) + if (types.HasFlag(FieldTypes.CompoundCollectionAttribute)) { builder.Append('Q'); } From f7047ec740a6bf6872775d81fdbee5ab0cea4855 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:56:26 +0200 Subject: [PATCH 52/63] Fixed: don't use IJsonApiOptions.SerializerOptions in change tracker --- src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 39b24be763..fb14fc647b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,6 +1,5 @@ using System.Text.Json; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; @@ -11,7 +10,6 @@ namespace JsonApiDotNetCore.Resources; public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { - private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; @@ -19,13 +17,11 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _requestAttributeValues; private Dictionary? _finallyStoredAttributeValues; - public ResourceChangeTracker(IJsonApiOptions options, IJsonApiRequest request, ITargetedFields targetedFields) + public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { - ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(targetedFields); - _options = options; _request = request; _targetedFields = targetedFields; } @@ -64,7 +60,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) if (value != null && (attribute.IsCompound || attribute.IsCollection)) { - value = JsonSerializer.SerializeToElement(value, _options.SerializerOptions); + value = JsonSerializer.SerializeToElement(value); } result.Add(attribute.PublicName, value); From 25e75aca137fcb026f6deb37fce10a9723eb9a04 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:07:54 +0200 Subject: [PATCH 53/63] CompoundType: Make ctor parameter non-optional --- .../Configuration/CompoundType.cs | 5 +++-- .../UnitTests/ResourceGraph/CompoundTypeTests.cs | 12 +----------- .../ResourceGraph/ResourceFieldAttributeTests.cs | 2 +- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index 560d2b7654..a8bb811c50 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -22,12 +22,13 @@ public sealed class CompoundType : IAttributeContainer /// public IReadOnlyCollection Attributes { get; } - public CompoundType(Type clrType, IReadOnlyCollection? attributes = null) + public CompoundType(Type clrType, IReadOnlyCollection attributes) { ArgumentNullException.ThrowIfNull(clrType); + ArgumentNullException.ThrowIfNull(attributes); ClrType = clrType; - Attributes = attributes ?? []; + Attributes = attributes; foreach (AttrAttribute attribute in Attributes) { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs index d06cecdf2c..1d130034da 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/CompoundTypeTests.cs @@ -8,16 +8,6 @@ namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; public sealed class CompoundTypeTests { - [Fact] - public void Cannot_create_compound_type_without_clr_type() - { - // Act - Action action = () => _ = new CompoundType(null!); - - // Assert - action.Should().ThrowExactly(); - } - [Fact] public void Can_lookup_attributes_in_compound_type() { @@ -56,7 +46,7 @@ public void Can_lookup_attributes_in_compound_type() public void Can_lookup_compound_type_in_resource_graph() { // Arrange - var compoundType = new CompoundType(typeof(TestAddress)); + var compoundType = new CompoundType(typeof(TestAddress), []); var resourceGraph = new JsonApiDotNetCore.Configuration.ResourceGraph(new HashSet().AsReadOnly(), new HashSet { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs index ddb149f2a8..35f67ac648 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -264,7 +264,7 @@ public void Evaluates_IsCollection_correctly() public void Can_set_and_get_IsCompound_and_CompoundType() { // Arrange - var compoundType = new CompoundType(typeof(TestResource)); + var compoundType = new CompoundType(typeof(TestResource), []); var attribute = new AttrAttribute { From 1f02d875c08dde433b8088c6796411a2e479c6a5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:10:44 +0200 Subject: [PATCH 54/63] Remove redundant public methods --- .../QueryClauseBuilderContext.cs | 7 ------- .../Response/ResponseModelAdapter.cs | 16 ++++------------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs index 6187a8579a..9c176e77e8 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -95,13 +95,6 @@ public QueryClauseBuilderContext WithSource(Expression source) return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); } - public QueryClauseBuilderContext WithExtensionType(Type extensionType) - { - ArgumentNullException.ThrowIfNull(extensionType); - - return new QueryClauseBuilderContext(Source, ResourceType, extensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); - } - public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) { ArgumentNullException.ThrowIfNull(lambdaScope); diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index b74642f37f..d6ae3a995a 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -235,24 +235,17 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return resourceObject; } - protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, - IImmutableSet fieldSet) - { - return ConvertAttributes(resource, resourceType, new SparseFieldSetExpression(fieldSet)); - } - - protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, - SparseFieldSetExpression fieldSetExpression) + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, SparseFieldSetExpression fieldSet) { ArgumentNullException.ThrowIfNull(resource); ArgumentNullException.ThrowIfNull(resourceType); - ArgumentNullException.ThrowIfNull(fieldSetExpression); + ArgumentNullException.ThrowIfNull(fieldSet); var attrMap = new Dictionary(resourceType.Attributes.Count); foreach (AttrAttribute attr in resourceType.Attributes) { - if (!fieldSetExpression.Fields.Contains(attr) || attr.Property.Name == nameof(Identifiable<>.Id)) + if (!fieldSet.Fields.Contains(attr) || attr.Property.Name == nameof(Identifiable<>.Id)) { continue; } @@ -273,8 +266,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (value != null && attr.IsCompound) { // Check if any field chains exist for this compound attribute with deeper paths - List subChains = fieldSetExpression.FieldChains - .Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) + List subChains = fieldSet.FieldChains.Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); var context = new CompoundSelectionContext(subChains.Count > 0 ? subChains : null); From 2002527649b4c060be0c71d047414e047efd0086 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:21:31 +0200 Subject: [PATCH 55/63] Use 'S' for scalar attributes, 'A' for any attribute, and support token unification in patterns --- .../Queries/Parsing/FilterParser.cs | 45 ++--- .../FieldChains/BuiltInPatterns.cs | 5 +- .../FieldChains/FieldChainPattern.cs | 27 +-- .../FieldChains/FieldTypeExtensions.cs | 111 ++++++++---- .../QueryStrings/FieldChains/PatternParser.cs | 5 +- .../FieldChains/PatternTextFormatter.cs | 71 +++++--- .../CompoundAttributesValidationTests.cs | 10 +- .../Decrypt/DecryptFilterParser.cs | 4 +- .../IsUpperCase/IsUpperCaseFilterParser.cs | 4 +- .../StringLength/LengthFilterParser.cs | 4 +- .../StringLength/LengthSortParser.cs | 2 +- .../Sum/SumFilterParseTests.cs | 4 +- .../CustomFunctions/Sum/SumFilterParser.cs | 8 +- .../FieldChainPatternMatchTests.cs | 60 ++++--- .../FieldChainPatternParseTests.cs | 168 +++++++++++++++--- .../CompoundFilterParseTests.cs | 4 +- .../CompoundSortParseTests.cs | 16 +- .../CompoundSparseFieldSetParseTests.cs | 4 +- .../QueryStringParameters/FilterParseTests.cs | 22 +-- .../LegacyFilterParseTests.cs | 12 +- .../QueryStringParameters/SortParseTests.cs | 12 +- .../SparseFieldSetParseTests.cs | 2 +- 22 files changed, 387 insertions(+), 213 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 1e894f3e5c..8ee055ec16 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -53,29 +53,13 @@ protected IAttributeContainer ContainerInScope { if (_containerStack.Count == 0) { - throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + throw new InvalidOperationException($"No resource or compound type is currently in scope. Call {nameof(Parse)}() first."); } return _containerStack.Peek(); } } - /// - /// Gets the resource type currently in scope. Call to temporarily change the current resource type. - /// - protected ResourceType ResourceTypeInScope - { - get - { - if (ContainerInScope is ResourceType resourceType) - { - return resourceType; - } - - throw new InvalidOperationException($"The container currently in scope is not a resource type: '{ContainerInScope}'."); - } - } - public FilterParser(IResourceFactory resourceFactory) { ArgumentNullException.ThrowIfNull(resourceFactory); @@ -94,7 +78,7 @@ public FilterExpression Parse(string source, ResourceType resourceType) _scalarCollectionElementTypeStack.Clear(); FilterExpression expression; - using (InScopeOfResourceType(resourceType)) + using (InScopeOfContainer(resourceType)) { expression = ParseFilter(); @@ -562,7 +546,7 @@ protected virtual HasExpression ParseHas() if (lastField is HasManyAttribute hasManyRelationship) { - using (InScopeOfResourceType(hasManyRelationship.RightType)) + using (InScopeOfContainer(hasManyRelationship.RightType)) { filter = ParseFilter(); } @@ -600,7 +584,7 @@ protected virtual IsTypeExpression ParseIsType() throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); } - if (ContainerInScope is not ResourceType) + if (ContainerInScope is not ResourceType resourceTypeInScope) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'isType' cannot be used on compound types.", position); @@ -613,7 +597,7 @@ protected virtual IsTypeExpression ParseIsType() 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); @@ -630,7 +614,7 @@ protected virtual IsTypeExpression ParseIsType() return null; } - return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ContainerInScope, "Relationship name or , expected."); } private ResourceType ParseDerivedType(ResourceType baseType) @@ -686,7 +670,7 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der { EatSingleCharacterToken(TokenKind.Comma); - using (InScopeOfResourceType(derivedType)) + using (InScopeOfContainer(derivedType)) { filter = ParseFilter(); } @@ -776,14 +760,6 @@ 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. - /// - protected IDisposable InScopeOfResourceType(ResourceType resourceType) - { - return InScopeOfContainer(resourceType); - } - /// /// Changes the container currently in scope and restores the original container when the return value is disposed. /// @@ -810,8 +786,11 @@ private void AssertContainerStackIsEmpty() { if (_containerStack.Count > 0) { - throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + - $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); + IAttributeContainer containerAtTopOfStack = _containerStack.Peek(); + string containerType = containerAtTopOfStack 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)}()."); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 17e64dcb9c..8a7d3ceb72 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -6,9 +6,8 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; public static class BuiltInPatterns { public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); - public static FieldChainPattern ToOneChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("O*A"); - public static FieldChainPattern ToOneOrCompoundChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("[OC]*A"); - public static FieldChainPattern ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne { get; } = FieldChainPattern.Parse("[OC]*[ACO]"); + public static FieldChainPattern ToOneOrCompoundChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("[OC]*S"); + public static FieldChainPattern ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne { get; } = FieldChainPattern.Parse("[OC]*[OSC]"); 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 29712f2b37..d4172e0556 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -14,10 +14,11 @@ public sealed class FieldChainPattern public const char ToManyRelationshipToken = 'M'; public const char ToOneRelationshipToken = 'O'; public const char RelationshipToken = 'R'; - public const char AttributeToken = 'A'; + public const char ScalarAttributeToken = 'S'; public const char CompoundAttributeToken = 'C'; public const char ScalarCollectionToken = 'L'; public const char CompoundCollectionToken = 'Q'; + public const char AttributeToken = 'A'; public const char FieldToken = 'F'; /// @@ -81,27 +82,33 @@ internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, /// /// /// - /// A + /// S /// - /// Matches an attribute. + /// Matches a scalar non-collection attribute. /// /// /// - /// C + /// L /// - /// Matches a compound attribute. + /// Matches a scalar collection attribute. /// /// /// - /// L + /// C /// - /// Matches a scalar collection. + /// Matches a compound non-collection attribute. /// /// /// /// Q /// - /// Matches a compound collection. + /// Matches a compound collection attribute. + /// + /// + /// + /// A + /// + /// Matches an attribute. /// /// /// @@ -168,8 +175,8 @@ public static FieldChainPattern Parse(string pattern) /// /// The match result. /// - public PatternMatchResult Match(string fieldChain, IAttributeContainer container, FieldChainPatternMatchOptions options, - ILoggerFactory? loggerFactory = null) + public PatternMatchResult Match(string fieldChain, IAttributeContainer container, + FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(fieldChain); ArgumentNullException.ThrowIfNull(container); diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs index 1cb95896b8..a21360e0ba 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -6,67 +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.Field)) { - WriteChoice("field", pluralize, prefix, false, builder, startOffset); - return; + writer.Write("field"); } - - if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + else { - WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); + WriteRelationships(choices, writer); + WriteAttributes(choices, writer); } + } - if (choices.HasFlag(FieldTypes.ToOneRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + private static void WriteRelationships(FieldTypes choices, ChoiceWriter writer) + { + if (choices.HasFlag(FieldTypes.Relationship)) { - WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); + writer.Write("relationship"); } - - if (choices.HasFlag(FieldTypes.Relationship)) + else if (choices.HasFlag(FieldTypes.ToOneRelationship)) { - WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + writer.Write("to-one relationship"); } - - if (choices.HasFlag(FieldTypes.ScalarAttribute)) + else if (choices.HasFlag(FieldTypes.ToManyRelationship)) { - WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); + writer.Write("to-many relationship"); } + } - if (choices.HasFlag(FieldTypes.CompoundAttribute)) + private static void WriteAttributes(FieldTypes choices, ChoiceWriter writer) + { + if (choices.HasFlag(FieldTypes.Attribute)) { - WriteChoice("compound attribute", pluralize, prefix, false, builder, startOffset); + writer.Write("attribute"); } - - if (choices.HasFlag(FieldTypes.ScalarCollectionAttribute)) + else { - WriteChoice("scalar collection", pluralize, prefix, false, builder, startOffset); + 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); + } } + } - if (choices.HasFlag(FieldTypes.CompoundCollectionAttribute)) + private static void WriteScalarAttributes(bool hasScalar, bool hasScalarCollection, ChoiceWriter writer) + { + if (hasScalar && hasScalarCollection) { - WriteChoice("compound collection", pluralize, prefix, false, builder, startOffset); + writer.Write("scalar attribute"); + } + else if (hasScalar) + { + writer.Write("scalar non-collection attribute"); + } + else if (hasScalarCollection) + { + writer.Write("scalar collection attribute"); } } - private static void WriteChoice(string typeText, bool pluralize, bool prefix, bool isAttribute, StringBuilder builder, int startOffset) + private static void WriteCompoundAttributes(bool hasCompound, bool hasCompoundCollection, ChoiceWriter writer) { - if (builder.Length > startOffset) + if (hasCompound && hasCompoundCollection) { - builder.Append(" or "); + writer.Write("compound attribute"); } - - if (prefix && !pluralize) + else if (hasCompound) + { + writer.Write("compound non-collection attribute"); + } + else if (hasCompoundCollection) { - builder.Append(isAttribute ? "an " : "a "); + 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/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs index 735cbabe94..da8024757a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -15,10 +15,11 @@ internal sealed class PatternParser ['M'] = Token.ToManyRelationship, ['O'] = Token.ToOneRelationship, ['R'] = Token.Relationship, - ['A'] = Token.ScalarAttribute, + ['S'] = Token.ScalarAttribute, ['L'] = Token.ScalarCollectionAttribute, ['C'] = Token.CompoundAttribute, ['Q'] = Token.CompoundCollectionAttribute, + ['A'] = Token.Attribute, ['F'] = Token.Field }; @@ -31,6 +32,7 @@ internal sealed class PatternParser [Token.CompoundAttribute] = FieldTypes.CompoundAttribute, [Token.ScalarCollectionAttribute] = FieldTypes.ScalarCollectionAttribute, [Token.CompoundCollectionAttribute] = FieldTypes.CompoundCollectionAttribute, + [Token.Attribute] = FieldTypes.Attribute, [Token.Field] = FieldTypes.Field }; @@ -190,6 +192,7 @@ private enum Token CompoundAttribute, ScalarCollectionAttribute, CompoundCollectionAttribute, + Attribute, Field } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs index 94932bb45c..aac133ea8f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -38,52 +38,65 @@ private static void WriteChoices(FieldTypes types, StringBuilder builder) if (types.HasFlag(FieldTypes.Field)) { builder.Append('F'); - return; } - - int startOffset = builder.Length; - - if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) + else { - builder.Append('M'); - } + int startOffset = builder.Length; - if (types.HasFlag(FieldTypes.ToOneRelationship) && !types.HasFlag(FieldTypes.Relationship)) - { - builder.Append('O'); + WriteRelationshipTokens(types, builder); + WriteAttributeTokens(types, builder); + + if (builder.Length - startOffset > 1) + { + builder.Insert(startOffset, '['); + builder.Append(']'); + } } + } + private static void WriteRelationshipTokens(FieldTypes types, StringBuilder builder) + { if (types.HasFlag(FieldTypes.Relationship)) { builder.Append('R'); } - - if (types.HasFlag(FieldTypes.ScalarAttribute)) - { - builder.Append('A'); - } - - if (types.HasFlag(FieldTypes.CompoundAttribute)) + else if (types.HasFlag(FieldTypes.ToOneRelationship)) { - builder.Append('C'); + builder.Append('O'); } - - if (types.HasFlag(FieldTypes.ScalarCollectionAttribute)) + else if (types.HasFlag(FieldTypes.ToManyRelationship)) { - builder.Append('L'); + builder.Append('M'); } + } - if (types.HasFlag(FieldTypes.CompoundCollectionAttribute)) + private static void WriteAttributeTokens(FieldTypes types, StringBuilder builder) + { + if (types.HasFlag(FieldTypes.Attribute)) { - builder.Append('Q'); + builder.Append('A'); } - - int charCount = builder.Length - startOffset; - - if (charCount > 1) + 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/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 3e4f2aca36..8a375ee456 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -37,7 +37,7 @@ public async Task Cannot_sort_on_compound_object() 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 attributes, followed by an attribute"); + error.Detail.Should().Contain("zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute"); } [Fact] @@ -56,7 +56,7 @@ public async Task Cannot_sort_on_collection_of_compound_attributes() 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 attributes, followed by an attribute"); + error.Detail.Should().Contain("zero or more to-one relationships or compound non-collection attributes, followed by a scalar non-collection attribute"); } [Fact] @@ -96,7 +96,7 @@ public async Task Cannot_navigate_directly_into_collection_without_has() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain( - "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute"); + "zero or more to-one relationships or compound non-collection attributes, followed by a to-one relationship or a non-collection attribute"); } [Fact] @@ -117,7 +117,7 @@ public async Task Cannot_filter_non_collection_with_has() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain( - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection"); + "zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute"); } [Fact] @@ -138,7 +138,7 @@ public async Task Cannot_use_count_on_non_collection() error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Contain( - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection"); + "zero or more to-one relationships or compound non-collection attributes, followed by a to-many relationship or a collection attribute"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs index 3a77948ac6..772202a0e6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs @@ -36,8 +36,8 @@ private DecryptExpression ParseDecrypt() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index 53b4bad22c..ca165f5c10 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -26,8 +26,8 @@ private IsUpperCaseExpression ParseIsUpperCase() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index bccdfef660..ed6d70a50c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -36,8 +36,8 @@ private LengthExpression ParseLength() int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs index ea01c4b49c..317be02541 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -35,7 +35,7 @@ private LengthExpression ParseLength(ResourceType resourceType) int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs index 853ac7ac4d..299c131ca0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index 70828dcf4d..2b88e8b548 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -38,7 +38,7 @@ private SumExpression ParseSum() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, "To-many relationship expected."); + ContainerInScope, "To-many relationship expected."); EatSingleCharacterToken(TokenKind.Comma); @@ -53,7 +53,7 @@ private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression tar { var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); - using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + using IDisposable scope = InScopeOfContainer(toManyRelationship.RightType); return ParseSumSelector(); } @@ -73,8 +73,8 @@ private QueryExpression ParseSumSelector() return function; } - ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, - ResourceTypeInScope, null); + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, ContainerInScope, null); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index f632642b5e..acc755ba6b 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; @@ -53,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)] @@ -181,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.")] @@ -225,10 +231,10 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("A", $"^{M}", $"Attribute on resource type '{T}' expected.")] [InlineData("A", $"^{O}", $"Attribute on resource type '{T}' expected.")] // Field sets: insufficient input - [InlineData("[AR]", "^", $"Relationship or attribute on resource type '{T}' expected.")] - [InlineData("[MO][AR]", $"{M}^", $"Relationship or attribute on resource type '{T}' expected.")] - [InlineData("[AR][AR]", $"{A}^", "Relationship or attribute expected.")] - [InlineData("[MO][MO][AR]", $"{O}.{M}^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("[AR]", "^", $"Field on resource type '{T}' expected.")] + [InlineData("[MO][AR]", $"{M}^", $"Field on resource type '{T}' expected.")] + [InlineData("[AR][AR]", $"{A}^", "Field expected.")] + [InlineData("[MO][MO][AR]", $"{O}.{M}^", $"Field on resource type '{T}' expected.")] [InlineData("[MA]", "^", $"To-many relationship or attribute on resource type '{T}' expected.")] [InlineData("[MO][MO]", $"{M}^", $"Relationship on resource type '{T}' expected.")] [InlineData("[MO][MO][MA]", $"{M}.{O}^", $"To-many relationship or attribute on resource type '{T}' expected.")] @@ -262,10 +268,10 @@ public void MatchSucceeds(string patternText, string fieldChainText) [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.")] - [InlineData("M*O*A", "^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("M*O*A", "^", $"Field on resource type '{T}' expected.")] [InlineData("M*O*A", $"{M}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] [InlineData("M*O*A", $"{M}.{M}.{O}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] - [InlineData("M?O?A", "^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("M?O?A", "^", $"Field on resource type '{T}' expected.")] [InlineData("M?O?A", $"{M}.{O}^", $"Attribute on resource type '{T}' expected.")] [InlineData("M+O+A", "^", $"To-many relationship on resource type '{T}' expected.")] [InlineData("M+O+A", $"{M}.{O}^", $"To-one relationship or attribute on resource type '{T}' expected.")] @@ -408,7 +414,7 @@ public void MatchSucceeds(string patternText, string fieldChainText) [InlineData("M+O", $"{M}.^{A}", $"Relationship on resource type '{T}' expected.")] [InlineData("M+OA", $"{M}^", $"Relationship on resource type '{T}' expected.")] [InlineData("O+A", $"{O}.^{M}", $"To-one relationship or attribute on resource type '{T}' expected.")] - [InlineData("[MA]+O", $"{M}^", $"Relationship or attribute on resource type '{T}' expected.")] + [InlineData("[MA]+O", $"{M}^", $"Field on resource type '{T}' expected.")] // Quantifiers at start and at end [InlineData("M*OA*", "^", $"Relationship on resource type '{T}' expected.")] [InlineData("M*OA*", $"{O}.{A}.^{A}", $"Field '{A}' does not exist.")] @@ -432,22 +438,22 @@ public void MatchSucceeds(string patternText, string fieldChainText) [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("A", $"^{C}", $"Attribute on resource type '{T}' expected.")] - [InlineData("A", $"^{L}", $"Attribute on resource type '{T}' expected.")] - [InlineData("A", $"^{Q}", $"Attribute on resource type '{T}' expected.")] - [InlineData("C", $"^{A}", $"Compound attribute on resource type '{T}' expected.")] - [InlineData("C", $"^{L}", $"Compound attribute on resource type '{T}' expected.")] - [InlineData("L", $"^{A}", $"Scalar collection on resource type '{T}' expected.")] - [InlineData("L", $"^{Q}", $"Scalar collection on resource type '{T}' expected.")] - [InlineData("Q", $"^{L}", $"Compound collection on resource type '{T}' expected.")] + [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("CA", $"{C}.^{C2}", "Attribute on compound type 'CompoundRoot' expected.")] - [InlineData("CA", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] - [InlineData("CCA", $"{C}.{C2}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundNested'.")] + [InlineData("CS", $"{C}.^{C2}", "Scalar non-collection attribute on compound type 'CompoundRoot' expected.")] + [InlineData("CS", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] + [InlineData("CCS", $"{C}.{C2}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundNested'.")] // Too much input on compound type [InlineData("C", $"{C}.^{A2}", "End of field chain expected.")] // Direct navigation into collection rejected - [InlineData("[OC]*A", $"{C}.^{Q}", "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] + [InlineData("[OC]*S", $"{C}.^{Q}", "To-one relationship or non-collection attribute on compound type 'CompoundRoot' expected.")] public void MatchFails(string patternText, string fieldChainText, string failureMessage) { // Arrange @@ -482,40 +488,40 @@ private sealed class Resource : Identifiable public ISet Children { get; set; } = new HashSet(); [Attr(IsCompound = true)] - public CompoundRoot? Details { get; set; } + public RootCompound? Details { get; set; } [Attr] public List Tags { get; set; } = []; [Attr(IsCompound = true)] - public List Items { get; set; } = []; + public List Items { get; set; } = []; } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CompoundRoot + public sealed class RootCompound { [Attr] public string? Title { get; set; } [Attr(IsCompound = true)] - public CompoundNested? Nested { get; set; } + public NestedCompound? Nested { get; set; } [Attr] public List Numbers { get; set; } = []; [Attr(IsCompound = true)] - public List Items { get; set; } = []; + public List Items { get; set; } = []; } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CompoundNested + public sealed class NestedCompound { [Attr] public string? Description { get; set; } } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CompoundItem + 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 a67c30177e..5911e2fcaf 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternParseTests.cs @@ -3,34 +3,35 @@ 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")] - [InlineData("C", "a compound attribute")] - [InlineData("L", "a scalar collection")] - [InlineData("Q", "a compound collection")] - [InlineData("[OC]", "a to-one relationship or a compound attribute")] - [InlineData("[MLQ]", "a to-many relationship or a scalar collection or a compound collection")] - [InlineData("[OC]*A", "zero or more to-one relationships or compound attributes, followed by an attribute")] - [InlineData("[OC]*[MLQ]", - "zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection")] - [InlineData("[OC]*[OAC]", - "zero or more to-one relationships or compound attributes, followed by a to-one relationship or an attribute or a compound attribute")] - [InlineData("C*A", "zero or more compound attributes, followed by an attribute")] - [InlineData("C+A", "one or more compound attributes, followed by an attribute")] - [InlineData("[OC]?", "an optional to-one relationship or compound attribute")] public void ParseSucceeds(string patternText, string displayText) { // Act @@ -43,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 @@ -69,17 +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")] - [InlineData("[MORAF]", "F", "a field")] - [InlineData("[AMAM]", "[MA]", "a to-many relationship or an attribute")] - [InlineData("[C]", "C", "a compound attribute")] - [InlineData("[L]", "L", "a scalar collection")] - [InlineData("[Q]", "Q", "a compound collection")] - [InlineData("[OCOC]", "[OC]", "a to-one relationship or a compound attribute")] - [InlineData("[QLML]", "[MLQ]", "a to-many relationship or a scalar collection or a compound collection")] + // 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")] + // 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/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs index 3a62933d18..a5bc50fafc 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs @@ -80,8 +80,8 @@ public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string pa [Theory] [InlineData("filter", "greaterThan(details^,null)", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] + "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or _ expected.")] [InlineData("filter", "has(tags,equals(^unknown,'work'))", "Function or _ expected.")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs index f9f19abb35..09915726ce 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs @@ -59,17 +59,17 @@ public void Reader_Read_Compound_Sort_Succeeds(string parameterName, string para [Theory] [InlineData("sort", "details^", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound attribute on compound type 'CompoundRoot' expected.")] + "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] [InlineData("sort", "^tags", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound attribute on resource type 'compoundModels' expected.")] + "Field chain on resource type 'compoundModels' 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 'compoundModels' expected.")] [InlineData("sort", "^items", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound attribute on resource type 'compoundModels' expected.")] + "Field chain on resource type 'compoundModels' 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 'compoundModels' expected.")] [InlineData("sort", "count(details^)", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + - "Relationship or compound attribute or scalar collection or compound collection on compound type 'CompoundRoot' expected.")] + "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] public void Reader_Read_Compound_Sort_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs index ce2cae0893..407c4f1e15 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs @@ -63,10 +63,10 @@ public void Reader_Read_Compound_SparseFieldSet_Succeeds(string parameterName, s [Theory] [InlineData("fields[compoundModels]", "details.^unknown", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] [InlineData("fields[compoundModels]", "parent.^name", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes or compound collections, followed by a field. " + + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes, followed by a field. " + "End of field chain expected.")] [InlineData("fields[compoundModels]", "tags.^something", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes or compound collections, followed by a field. " + + "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes, followed by a field. " + "End of field chain expected.")] public void Reader_Read_Compound_SparseFieldSet_Fails(string parameterName, string parameterValue, string errorMessage) { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index 864c5c035a..4487f8cf51 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -100,8 +100,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter[posts]", "equals(author,^'some')", "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 or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound 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.")] @@ -124,16 +124,16 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "contains(^equals(title,'x'),'a')", "Function that returns type 'String' 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 or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound 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", "any(^null,'a','b')", "Field name expected.")] [InlineData("filter", "any(^'a','b','c')", "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(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 or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound 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.")] @@ -239,7 +239,7 @@ public void Throws_When_ResourceType_Scope_Not_Disposed() // 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] @@ -254,7 +254,7 @@ public void Throws_When_No_ResourceType_In_Scope() 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 resource or compound type is currently in scope. Call Parse() first."); } private sealed class NotDisposingFilterParser(IResourceFactory resourceFactory) @@ -263,7 +263,7 @@ private sealed class NotDisposingFilterParser(IResourceFactory resourceFactory) protected override FilterExpression ParseFilter() { // Forgot to dispose the return value. - _ = InScopeOfResourceType(ResourceTypeInScope); + _ = InScopeOfContainer(ContainerInScope); return base.ParseFilter(); } @@ -274,8 +274,8 @@ private sealed class ResourceTypeAccessingFilterParser(IResourceFactory resource { protected override void Tokenize(string source) { - // There is no resource type in scope yet. - _ = ResourceTypeInScope; + // There is no container in scope yet. + _ = ContainerInScope; base.Tokenize(source); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index 73ff377b2e..030135e315 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -36,8 +36,8 @@ public LegacyFilterParseTests() [InlineData("filter[]", "Expected field name between brackets 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 or compound attributes, followed by a to-one relationship or an attribute or a compound attribute. " + - "To-one relationship or attribute or compound 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 to-one relationship or a non-collection attribute. " + + "To-one relationship or non-collection 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) { @@ -63,11 +63,11 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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:has(author)", - "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + - "Relationship or compound attribute or scalar collection or compound collection 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 or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + - "Relationship or compound attribute or scalar collection or compound collection 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 diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index 4af066cb5d..0cf77bfbc2 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -97,8 +97,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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 or compound attributes, followed by an attribute. " + - "To-one relationship or attribute or compound 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,11 +107,11 @@ 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 or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + - "Relationship or compound attribute or scalar collection or compound collection 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 or compound attributes, followed by a to-many relationship or a scalar collection or a compound collection. " + - "Relationship or compound attribute or scalar collection or compound collection 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.")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index 48f9004794..a4831a4cb0 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -90,7 +90,7 @@ 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: zero or more compound attributes or compound collections, followed by a field. " + + "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) { From ec7122ffb2fbcfb8561bf9737a08144d02ad558c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:21:29 +0200 Subject: [PATCH 56/63] Refactor FilterParser by making _ a first-class token --- .../CollectionElementValueExpression.cs | 48 --------------- .../Expressions/QueryExpressionRewriter.cs | 2 +- .../Expressions/QueryExpressionVisitor.cs | 2 +- .../ScalarCollectionElementValueExpression.cs | 61 +++++++++++++++++++ .../Parsing/AttributeContainerFilterScope.cs | 29 +++++++++ .../Queries/Parsing/FilterScope.cs | 14 +++++ .../Queries/Parsing/QueryTokenizer.cs | 9 +-- .../ScalarCollectionElementFilterScope.cs | 28 +++++++++ .../Queries/Parsing/TokenKind.cs | 1 + .../QueryableBuilding/WhereClauseBuilder.cs | 4 +- .../QueryStrings/FieldChains/MatchError.cs | 10 +++ .../CompoundAttributesValidationTests.cs | 19 ++++++ .../IntegrationTests/QueryStrings/Blog.cs | 3 + .../Decrypt/DecryptExpression.cs | 16 +++-- .../Decrypt/DecryptFilterParseTests.cs | 4 ++ .../Decrypt/DecryptFilterParser.cs | 39 ++++++++++-- .../Decrypt/DecryptFilterTests.cs | 39 ++++++++++++ .../Decrypt/DecryptWhereClauseBuilder.cs | 2 +- .../IsUpperCase/IsUpperCaseExpression.cs | 27 ++++---- .../IsUpperCaseFilterParseTests.cs | 3 + .../IsUpperCase/IsUpperCaseFilterParser.cs | 39 ++++++++++-- .../IsUpperCase/IsUpperCaseFilterTests.cs | 38 ++++++++++++ .../IsUpperCaseWhereClauseBuilder.cs | 16 ++++- .../StringLength/LengthExpression.cs | 26 ++++---- .../StringLength/LengthFilterParseTests.cs | 4 ++ .../StringLength/LengthFilterParser.cs | 39 ++++++++++-- .../StringLength/LengthFilterTests.cs | 38 ++++++++++++ .../StringLength/LengthOrderClauseBuilder.cs | 2 +- .../StringLength/LengthWhereClauseBuilder.cs | 2 +- .../CustomFunctions/Sum/SumFilterParser.cs | 16 ++++- .../FieldChainPatternMatchTests.cs | 8 +-- .../UnitTests/Queries/QueryExpressionTests.cs | 8 +-- 32 files changed, 479 insertions(+), 117 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs deleted file mode 100644 index ed1378d60b..0000000000 --- a/src/JsonApiDotNetCore/Queries/Expressions/CollectionElementValueExpression.cs +++ /dev/null @@ -1,48 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Expressions; - -/// -/// Represents the element value of a collection (such as a scalar collection) in an expression tree. -/// -[PublicAPI] -public sealed class CollectionElementValueExpression : IdentifierExpression -{ - internal const string Symbol = "_"; - - /// - /// The CLR type of the collection element (e.g. , ). - /// - public Type ElementType { get; } - - public CollectionElementValueExpression(Type elementType) - { - ArgumentNullException.ThrowIfNull(elementType); - ElementType = elementType; - } - - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitCollectionElementValue(this, argument); - } - - public override string ToString() - { - return Symbol; - } - - public override string ToFullString() - { - return ToString(); - } - - public override bool Equals(object? obj) - { - return ReferenceEquals(this, obj) || (obj is CollectionElementValueExpression other && ElementType == other.ElementType); - } - - public override int GetHashCode() - { - return ElementType.GetHashCode(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 6f62bdfa20..a3aecc3408 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -51,7 +51,7 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } - public override QueryExpression VisitCollectionElementValue(CollectionElementValueExpression expression, TArgument argument) + public override QueryExpression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, TArgument argument) { return expression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index fdd9abd9f9..ae7a57f507 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -44,7 +44,7 @@ public virtual TResult VisitNullConstant(NullConstantExpression expression, TArg return DefaultVisit(expression, argument); } - public virtual TResult VisitCollectionElementValue(CollectionElementValueExpression expression, TArgument argument) + public virtual TResult VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression 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..5b76f4c086 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs @@ -0,0 +1,61 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the element value of a scalar collection in an expression tree. +/// +[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/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/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/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs index bf7b714e96..c1a6d539e2 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 = @@ -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); @@ -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/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/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index c36694df90..c82b4fedbc 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -216,7 +216,7 @@ private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderCont return typeof(int); } - if (expression is CollectionElementValueExpression elementValue) + if (expression is ScalarCollectionElementValueExpression elementValue) { return elementValue.ElementType; } @@ -252,7 +252,7 @@ public override Expression VisitLiteralConstant(LiteralConstantExpression expres return SystemExpressionBuilder.CloseOver(expression.TypedValue); } - public override Expression VisitCollectionElementValue(CollectionElementValueExpression expression, QueryClauseBuilderContext context) + public override Expression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, QueryClauseBuilderContext context) { return context.LambdaScope.Accessor; } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs index 83b8fcfbec..045271c1e7 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -58,6 +58,11 @@ public static MatchError CreateForMultipleDerivedTypes(int position, ResourceTyp public static MatchError CreateForFieldTypeMismatch(int position, IAttributeContainer? container, FieldTypes choices) { + if (container is CompoundType) + { + choices &= ~FieldTypes.Relationship; + } + var builder = new MessageBuilder(); builder.WriteChoices(choices); @@ -71,6 +76,11 @@ public static MatchError CreateForFieldTypeMismatch(int position, IAttributeCont public static MatchError CreateForTooMuchInput(int position, IAttributeContainer? container, FieldTypes choices) { + if (container is CompoundType) + { + choices &= ~FieldTypes.Relationship; + } + var builder = new MessageBuilder(); builder.WriteEndOfChain(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 8a375ee456..abe37e97c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -160,6 +160,25 @@ public async Task Cannot_use_isType_inside_compound_attribute() error.Detail.Should().Contain("Function 'isType' cannot be used on compound 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() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index b58c446ced..3fc543c882 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -14,6 +14,9 @@ public sealed class Blog : Identifiable [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); 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..26c37430da 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs @@ -39,6 +39,8 @@ public DecryptFilterParseTests() [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", "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,6 +70,8 @@ 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) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs index 772202a0e6..98707c21e5 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,10 +35,42 @@ 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("_ expected.", position); + } + + if (collectionScope.ElementType != typeof(string)) + { + throw new QueryParseException("Element of type 'String' expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; @@ -47,8 +80,6 @@ private DecryptExpression ParseDecrypt() throw new QueryParseException("Attribute of type 'String' 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..ecbb6ac004 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -39,6 +39,8 @@ public IsUpperCaseFilterParseTests() [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", "has(tags,isUpperCase(^some))", "_ expected.")] + [InlineData("filter", "has(tags,isUpperCase(^))", "_ expected.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Arrange @@ -66,6 +68,7 @@ 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) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index ca165f5c10..89c9c7b959 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,10 +25,42 @@ 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("_ expected.", position); + } + + if (collectionScope.ElementType != typeof(string)) + { + throw new QueryParseException("Element of type 'String' expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; @@ -37,8 +70,6 @@ private IsUpperCaseExpression ParseIsUpperCase() throw new QueryParseException("Attribute of type 'String' 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..7fef5fa9b1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -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..ffd82c8762 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -39,6 +39,8 @@ public LengthFilterParseTests() [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", "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,6 +68,8 @@ 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) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index ed6d70a50c..3787935a05 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,10 +35,42 @@ 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("_ expected.", position); + } + + if (collectionScope.ElementType != typeof(string)) + { + throw new QueryParseException("Element of type 'String' expected.", position); + } + + return elementValue; + } + + private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilterScope containerScope) + { int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; @@ -47,8 +80,6 @@ private LengthExpression ParseLength() throw new QueryParseException("Attribute of type 'String' 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/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/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index 2b88e8b548..9e6a5879ae 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,11 +35,17 @@ protected override FunctionExpression ParseFunction() private SumExpression ParseSum() { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'sum' cannot be used on scalar collection elements.", position); + } + EatText(SumExpression.Keyword); EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, - ContainerInScope, "To-many relationship expected."); + containerScope.Container, "To-many relationship expected."); EatSingleCharacterToken(TokenKind.Comma); @@ -59,6 +66,11 @@ private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression tar 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!)) @@ -74,7 +86,7 @@ private QueryExpression ParseSumSelector() } ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index acc755ba6b..ef7f757504 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -447,13 +447,13 @@ public void MatchSucceeds(string patternText, string fieldChainText) [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 'CompoundRoot' expected.")] - [InlineData("CS", $"{C}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] - [InlineData("CCS", $"{C}.{C2}.^{X}", "Field 'unknown' does not exist on compound type 'CompoundNested'.")] + [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}", "To-one relationship or non-collection attribute on compound type 'CompoundRoot' expected.")] + [InlineData("[OC]*S", $"{C}.^{Q}", "Non-collection attribute on compound type 'RootCompound' expected.")] public void MatchFails(string patternText, string fieldChainText, string failureMessage) { // Arrange diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs index 9ad840a800..8742b86726 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs @@ -30,8 +30,8 @@ public sealed class QueryExpressionTests TestExpressionFactory.Instance.Count() ], [ - TestExpressionFactory.Instance.ElementValue(), - TestExpressionFactory.Instance.ElementValue() + TestExpressionFactory.Instance.ScalarCollectionElementValue(), + TestExpressionFactory.Instance.ScalarCollectionElementValue() ], [ TestExpressionFactory.Instance.Has(), @@ -339,9 +339,9 @@ public CountExpression Count() return new CountExpression(ResourceFieldChainForChildren()); } - public CollectionElementValueExpression ElementValue() + public ScalarCollectionElementValueExpression ScalarCollectionElementValue() { - return new CollectionElementValueExpression(typeof(string)); + return new ScalarCollectionElementValueExpression(typeof(string)); } public HasExpression Has() From 07ea02a565ed380bec7fdf6a4e7d44136000b30c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:00:26 +0100 Subject: [PATCH 57/63] Refactor filter parser: single stack, always allow equality comparison of attribute with null --- JsonApiDotNetCore.slnx.DotSettings | 1 + .../Configuration/ResourceGraphBuilder.cs | 12 +- .../ScalarCollectionElementValueExpression.cs | 11 +- .../Queries/Parsing/FilterParser.cs | 407 +++++++++--------- .../FieldChains/BuiltInPatterns.cs | 2 +- .../QueryStrings/FilterTests.cs | 28 ++ .../AttributeTypeFilterTests.cs | 36 +- .../CompoundAttributesReadTests.cs | 64 +++ .../CompoundAttributesValidationTests.cs | 2 +- .../IntegrationTests/QueryStrings/Blog.cs | 8 +- .../IntegrationTests/QueryStrings/BlogPost.cs | 2 +- .../IsUpperCase/IsUpperCaseFilterTests.cs | 8 +- .../CustomFunctions/Sum/SumFilterTests.cs | 2 +- .../QueryStrings/Filtering/FilterTests.cs | 350 ++++++++++++++- .../QueryStrings/QueryStringFakers.cs | 6 +- .../QueryStrings/WebAccount.cs | 2 +- .../UnitTests/Queries/QueryExpressionTests.cs | 13 +- .../CompoundFilterParseTests.cs | 175 -------- .../CompoundSortParseTests.cs | 139 ------ .../CompoundSparseFieldSetParseTests.cs | 140 ------ .../QueryStringParameters/FilterParseTests.cs | 91 +++- .../LegacyFilterParseTests.cs | 4 +- 22 files changed, 805 insertions(+), 698 deletions(-) delete mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs delete mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs delete mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs diff --git a/JsonApiDotNetCore.slnx.DotSettings b/JsonApiDotNetCore.slnx.DotSettings index 1509f84a1f..8933b09cbb 100644 --- a/JsonApiDotNetCore.slnx.DotSettings +++ b/JsonApiDotNetCore.slnx.DotSettings @@ -599,6 +599,7 @@ True True True + True True Replace argument null check using throw expression with ArgumentNullException.ThrowIfNull True diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index c0b6461c7c..caa384a548 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -14,7 +14,7 @@ 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; @@ -86,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); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs index 5b76f4c086..b1e018c1d4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs @@ -3,7 +3,11 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the element value of a scalar collection in an expression tree. +/// Represents the element value of a scalar collection (_), resulting from text such as: +/// +/// has(tags,equals(_,'Work')) +/// +/// . /// [PublicAPI] public sealed class ScalarCollectionElementValueExpression : IdentifierExpression @@ -22,21 +26,25 @@ public ScalarCollectionElementValueExpression(Type 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)) @@ -54,6 +62,7 @@ public override bool Equals(object? obj) return ElementType == other.ElementType; } + /// public override int GetHashCode() { return ElementType.GetHashCode(); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 8ee055ec16..d64f51d76c 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; @@ -14,8 +15,6 @@ namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public class FilterParser : QueryExpressionParser, IFilterParser { - private const string CollectionElementSymbol = CollectionElementValueExpression.Symbol; - private static readonly HashSet FilterKeywords = [ Keywords.Not, @@ -36,27 +35,22 @@ public class FilterParser : QueryExpressionParser, IFilterParser ]; private readonly IResourceFactory _resourceFactory; - private readonly Stack _containerStack = new(); - private readonly Stack _scalarCollectionElementTypeStack = new(); - - /// - /// Gets the CLR type of the scalar collection element currently in scope, or null if not in scope of a scalar collection. - /// - protected Type? ScalarCollectionElementTypeInScope => _scalarCollectionElementTypeStack.Count > 0 ? _scalarCollectionElementTypeStack.Peek() : null; + private readonly Stack _scopeStack = new(); /// - /// Gets the container currently in scope. Call to temporarily change the current container. + /// Gets the current filter scope. Call or to temporarily change the current + /// scope. /// - protected IAttributeContainer ContainerInScope + protected FilterScope CurrentScope { get { - if (_containerStack.Count == 0) + if (_scopeStack.Count == 0) { - throw new InvalidOperationException($"No resource or compound type is currently in scope. Call {nameof(Parse)}() first."); + throw new InvalidOperationException($"No filter scope is currently active. Call {nameof(Parse)}() first."); } - return _containerStack.Peek(); + return _scopeStack.Peek(); } } @@ -74,21 +68,17 @@ public FilterExpression Parse(string source, ResourceType resourceType) Tokenize(source); - _containerStack.Clear(); - _scalarCollectionElementTypeStack.Clear(); - FilterExpression expression; + _scopeStack.Clear(); using (InScopeOfContainer(resourceType)) { - expression = ParseFilter(); + FilterExpression expression = ParseFilter(); AssertTokenStackIsEmpty(); - } - - AssertContainerStackIsEmpty(); - AssertScalarCollectionElementTypeStackIsEmpty(); + AssertFilterScopeStackHasOnlyRoot(); - return expression; + return expression; + } } protected virtual bool IsFunction(string name) @@ -116,7 +106,7 @@ protected virtual FunctionExpression ParseFunction() private CountExpression ParseCount() { - if (ScalarCollectionElementTypeInScope != null) + if (CurrentScope is not AttributeContainerFilterScope containerScope) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'count' cannot be used on scalar collection elements.", position); @@ -126,7 +116,7 @@ private CountExpression ParseCount() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); EatSingleCharacterToken(TokenKind.CloseParen); @@ -229,69 +219,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 }) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) { - if (IsFunction(nextToken.Value!)) - { - return ParseFunction(); - } + return ParseFunction(); + } - if (ScalarCollectionElementTypeInScope != null) + switch (CurrentScope) + { + case ScalarCollectionElementFilterScope collectionScope: { - if (nextToken.Value == CollectionElementSymbol) + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue != null) { - EatText(CollectionElementSymbol); - return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); + return elementValue; } - throw new QueryParseException("Function or _ expected.", nextToken.Position); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function or _ expected.", position); } - } + case AttributeContainerFilterScope containerScope: + { + // Allow equality comparison of a to-one relationship or attribute with null. + FieldChainPattern pattern = isEqualityComparison + ? BuiltInPatterns.ToOneOrCompoundChainEndingInToOneOrAttribute + : BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute; - if (ScalarCollectionElementTypeInScope != null) - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or _ expected.", position); + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); + } + default: + { + throw new UnreachableException(); + } } - - // Allow equality comparison of a to-one relationship or compound attribute with null. - FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals - ? BuiltInPatterns.ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne - : BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute; - - return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ContainerInScope, "Function or field name expected."); } - private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) + /// + /// Attempts to parse the underscore (_), which represents the current scalar collection element. + /// + protected ScalarCollectionElementValueExpression? TryParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope) { - if (leftTerm is CollectionElementValueExpression elementValue) + ArgumentNullException.ThrowIfNull(collectionScope); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Underscore) { - ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(elementValue.ElementType); - return ParseTypedComparisonRightTerm(elementValue.ElementType, constantValueConverter); + EatSingleCharacterToken(TokenKind.Underscore); + return new ScalarCollectionElementValueExpression(collectionScope.ElementType); } + return null; + } + + private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm, bool isEqualityComparison) + { if (leftTerm is ResourceFieldChainExpression leftFieldChain) { ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; - if (leftLastField is HasOneAttribute or AttrAttribute { IsCompound: true, IsCollection: false }) + if (leftLastField is HasOneAttribute or AttrAttribute { IsCompound: true } or AttrAttribute { IsCollection: true }) { return ParseNull(); } @@ -299,36 +302,43 @@ 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, field chain or element value, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + throw new UnreachableException(); } - private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter) + private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter, bool isEqualityComparison) { - bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); + // 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 records. + bool allowNull = isEqualityComparison || RuntimeTypeConverter.CanContainNull(leftType); - // @formatter:nested_ternary_style expanded - string errorMessage = allowNull - ? ScalarCollectionElementTypeInScope != null - ? "Function, value between quotes, _ or null expected." - : "Function, field name, value between quotes or null expected." - : ScalarCollectionElementTypeInScope != null - ? "Function, value between quotes or _ expected." - : "Function, field name or value between quotes expected."; - // @formatter:nested_ternary_style restore + string errorMessage = (CurrentScope, allowNull) switch + { + (ScalarCollectionElementFilterScope, true) => "Function, value between quotes, _ or null expected.", + (ScalarCollectionElementFilterScope, false) => "Function, value between quotes or _ expected.", + (AttributeContainerFilterScope, true) => "Function, field name, value between quotes or null expected.", + (AttributeContainerFilterScope, false) => "Function, field name or value between quotes expected.", + _ => throw new UnreachableException() + }; if (TokenStack.TryPeek(out Token? nextToken)) { - if (nextToken is { Kind: TokenKind.QuotedText }) + if (nextToken.Kind == TokenKind.QuotedText) { TokenStack.Pop(); @@ -336,6 +346,16 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return new LiteralConstantExpression(constantValue, nextToken.Value!); } + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) + { + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + + if (elementValue != null) + { + return elementValue; + } + } + if (nextToken.Kind == TokenKind.Text) { if (nextToken.Value == Keywords.Null) @@ -354,19 +374,11 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal return ParseFunction(); } - if (ScalarCollectionElementTypeInScope != null) + if (CurrentScope is AttributeContainerFilterScope containerScope) { - if (nextToken.Value == CollectionElementSymbol) - { - EatText(CollectionElementSymbol); - return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); - } - - throw new QueryParseException(errorMessage, nextToken.Position); + return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, + containerScope.Container, errorMessage); } - - return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, ContainerInScope, - errorMessage); } } @@ -396,58 +408,59 @@ protected virtual MatchTextExpression ParseTextMatch(string operatorName) private QueryExpression ParseTextMatchLeftTerm() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) { - if (IsFunction(nextToken.Value!)) - { - FunctionExpression targetFunction = ParseFunction(); + FunctionExpression targetFunction = ParseFunction(); - if (targetFunction.ReturnType != typeof(string)) - { - throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); - } - - return targetFunction; + if (targetFunction.ReturnType != typeof(string)) + { + throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); } - if (ScalarCollectionElementTypeInScope != null) + return targetFunction; + } + + switch (CurrentScope) + { + case ScalarCollectionElementFilterScope collectionScope: { - if (nextToken.Value == CollectionElementSymbol) - { - EatText(CollectionElementSymbol); + int position = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - if (ScalarCollectionElementTypeInScope != typeof(string)) + if (elementValue != null) + { + if (elementValue.ElementType != typeof(string)) { - throw new QueryParseException("Element of type 'String' expected.", nextToken.Position); + throw new QueryParseException("Element of type 'String' expected.", position); } - return new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); + return elementValue; } - throw new QueryParseException("Function or _ expected.", nextToken.Position); + throw new QueryParseException("Function or _ expected.", position); } - } - - if (ScalarCollectionElementTypeInScope != null) - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or _ expected.", position); - } + case AttributeContainerFilterScope containerScope: + { + int chainStartPosition = GetNextTokenPositionOrEnd(); - int chainStartPosition = GetNextTokenPositionOrEnd(); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; - var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + if (targetAttribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } - if (targetAttribute.Property.PropertyType != typeof(string)) - { - int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); - throw new QueryParseException("Attribute of type 'String' expected.", position); + return targetAttributeChain; + } + default: + { + throw new UnreachableException(); + } } - - return targetAttributeChain; } protected virtual AnyExpression ParseAny() @@ -482,49 +495,49 @@ protected virtual AnyExpression ParseAny() private (QueryExpression matchTarget, Func constantValueConverterFactory) ParseAnyLeftTerm() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) { - if (IsFunction(nextToken.Value!)) - { - FunctionExpression targetFunction = ParseFunction(); + FunctionExpression targetFunction = ParseFunction(); - Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); - return (targetFunction, functionConverterFactory); - } + Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); + return (targetFunction, functionConverterFactory); + } - if (ScalarCollectionElementTypeInScope != null) + switch (CurrentScope) + { + case ScalarCollectionElementFilterScope collectionScope: { - if (nextToken.Value == CollectionElementSymbol) - { - EatText(CollectionElementSymbol); - var targetExpression = new CollectionElementValueExpression(ScalarCollectionElementTypeInScope); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - Func functionConverterFactory = () => GetConstantValueConverterForType(ScalarCollectionElementTypeInScope); - return (targetExpression, functionConverterFactory); + if (elementValue != null) + { + Func functionConverterFactory = () => GetConstantValueConverterForType(elementValue.ElementType); + return (elementValue, functionConverterFactory); } - throw new QueryParseException("Function or _ expected.", nextToken.Position); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function or _ expected.", position); } - } - - if (ScalarCollectionElementTypeInScope != null) - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or _ expected.", position); - } - - ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + case AttributeContainerFilterScope containerScope: + { + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, + FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); - var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; - Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); - return (targetAttributeChain, attributeConverterFactory); + Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); + return (targetAttributeChain, attributeConverterFactory); + } + default: + { + throw new UnreachableException(); + } + } } protected virtual HasExpression ParseHas() { - if (ScalarCollectionElementTypeInScope != null) + if (CurrentScope is not AttributeContainerFilterScope containerScope) { int position = GetNextTokenPositionOrEnd(); throw new QueryParseException("Function 'has' cannot be used on scalar collection elements.", position); @@ -534,7 +547,7 @@ protected virtual HasExpression ParseHas() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, - FieldChainPatternMatchOptions.None, ContainerInScope, null); + FieldChainPatternMatchOptions.None, containerScope.Container, null); FilterExpression? filter = null; @@ -560,11 +573,9 @@ protected virtual HasExpression ParseHas() } else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } scalarCollectionAttr) { - Type elementType = CollectionConverter.Instance.FindCollectionElementType(scalarCollectionAttr.Property.PropertyType) ?? - throw new InvalidOperationException( - $"Cannot determine element type for scalar collection attribute '{scalarCollectionAttr.Property.Name}'."); + Type elementType = CollectionConverter.Instance.FindCollectionElementType(scalarCollectionAttr.Property.PropertyType)!; - using (InScopeOfScalarCollectionElementType(elementType)) + using (InScopeOfScalarCollection(elementType)) { filter = ParseFilter(); } @@ -578,22 +589,35 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { - if (ScalarCollectionElementTypeInScope != null) - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); - } + ResourceType resourceTypeInScope; - if (ContainerInScope is not ResourceType resourceTypeInScope) + switch (CurrentScope) { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'isType' cannot be used on compound types.", position); + case AttributeContainerFilterScope { Container: ResourceType resourceType }: + { + resourceTypeInScope = resourceType; + break; + } + case AttributeContainerFilterScope: + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'isType' cannot be used on compound types.", position); + } + case ScalarCollectionElementFilterScope: + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); + } + default: + { + throw new UnreachableException(); + } } EatText(Keywords.IsType); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(resourceTypeInScope); EatSingleCharacterToken(TokenKind.Comma); @@ -607,14 +631,14 @@ protected virtual IsTypeExpression ParseIsType() return new IsTypeExpression(targetToOneRelationship, derivedType, child); } - private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + private ResourceFieldChainExpression? TryParseToOneRelationshipChain(ResourceType resourceTypeInScope) { if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { return null; } - return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ContainerInScope, "Relationship name or , expected."); + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, resourceTypeInScope, "To-one relationship name or , expected."); } private ResourceType ParseDerivedType(ResourceType baseType) @@ -761,65 +785,60 @@ protected override void ValidateField(ResourceFieldAttribute field, int position } /// - /// Changes the container currently in scope and restores the original container 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 InScopeOfContainer(IAttributeContainer container) { ArgumentNullException.ThrowIfNull(container); - _containerStack.Push(container); - return new PopContainerOnDispose(_containerStack); + var scope = new AttributeContainerFilterScope(container); + return PushFilterScope(scope); } /// - /// Changes the scalar collection element type currently in scope and restores the original type when the return value is disposed. + /// 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 InScopeOfScalarCollectionElementType(Type elementType) + protected IDisposable InScopeOfScalarCollection(Type collectionElementType) { - ArgumentNullException.ThrowIfNull(elementType); + ArgumentNullException.ThrowIfNull(collectionElementType); - _scalarCollectionElementTypeStack.Push(elementType); - return new PopScalarCollectionElementTypeOnDispose(_scalarCollectionElementTypeStack); + var scope = new ScalarCollectionElementFilterScope(collectionElementType); + return PushFilterScope(scope); } - private void AssertContainerStackIsEmpty() + private PopFilterScopeOnDispose PushFilterScope(FilterScope scope) { - if (_containerStack.Count > 0) - { - IAttributeContainer containerAtTopOfStack = _containerStack.Peek(); - string containerType = containerAtTopOfStack 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)}()."); - } + _scopeStack.Push(scope); + return new PopFilterScopeOnDispose(_scopeStack); } - private void AssertScalarCollectionElementTypeStackIsEmpty() + private void AssertFilterScopeStackHasOnlyRoot() { - if (_scalarCollectionElementTypeStack.Count > 0) + if (_scopeStack.Count > 1) { - 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(InScopeOfScalarCollectionElementType)}()."); - } - } + FilterScope scopeAtTopOfStack = _scopeStack.Peek(); - private sealed class PopContainerOnDispose(Stack containerStack) : IDisposable - { - private readonly Stack _containerStack = containerStack; + if (scopeAtTopOfStack is AttributeContainerFilterScope containerScope) + { + string containerType = containerScope.Container is ResourceType ? "resource type" : "compound type"; - public void Dispose() - { - _containerStack.Pop(); + 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 PopScalarCollectionElementTypeOnDispose(Stack scalarCollectionElementTypeStack) : IDisposable + private sealed class PopFilterScopeOnDispose(Stack scopeStack) : IDisposable { - private readonly Stack _scalarCollectionElementTypeStack = scalarCollectionElementTypeStack; + private readonly Stack _scopeStack = scopeStack; public void Dispose() { - _scalarCollectionElementTypeStack.Pop(); + _scopeStack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs index 8a7d3ceb72..eb8d47bde4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -7,7 +7,7 @@ public static class BuiltInPatterns { public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); public static FieldChainPattern ToOneOrCompoundChainEndingInScalarAttribute { get; } = FieldChainPattern.Parse("[OC]*S"); - public static FieldChainPattern ToOneOrCompoundChainEndingInScalarOrCompoundAttributeOrToOne { get; } = FieldChainPattern.Parse("[OC]*[OSC]"); + 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/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/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs index e92a87c32b..365d5fdc51 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] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs index ba217e3fe8..1603ecbd8f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesReadTests.cs @@ -145,6 +145,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { @@ -810,6 +833,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index abe37e97c9..2be1420152 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -96,7 +96,7 @@ public async Task Cannot_navigate_directly_into_collection_without_has() 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 a non-collection attribute"); + "zero or more to-one relationships or compound non-collection attributes, followed by a to-one relationship or an attribute"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index 3fc543c882..85b9f3afdc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -11,17 +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(); + 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/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs index 7fef5fa9b1..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 => { 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/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index ca403cfb60..b0ab58474c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -19,6 +19,7 @@ public FilterTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.App.Services.GetRequiredService(); options.EnableLegacyFilterNotation = false; @@ -117,7 +118,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 +142,351 @@ 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); + } } 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/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/UnitTests/Queries/QueryExpressionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs index 8742b86726..22d0e537dd 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs @@ -166,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) { @@ -175,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) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs deleted file mode 100644 index a5bc50fafc..0000000000 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundFilterParseTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.ComponentModel.Design; -using System.Net; -using FluentAssertions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Parsing; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; - -public sealed class CompoundFilterParseTests -{ - private readonly FilterQueryStringParameterReader _reader; - - public CompoundFilterParseTests() - { - var options = new JsonApiOptions - { - EnableLegacyFilterNotation = false - }; - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - - var request = new JsonApiRequest - { - PrimaryResourceType = resourceGraph.GetResourceType(), - IsCollection = true - }; - - using var serviceProvider = new ServiceContainer(); - var resourceFactory = new ResourceFactory(serviceProvider); - var scopeParser = new QueryStringParameterScopeParser(); - var valueParser = new FilterParser(resourceFactory); - _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, request, resourceGraph, options); - } - - [Theory] - [InlineData("filter", "equals(details.title,'abc')")] - [InlineData("filter", "equals(details,null)")] - [InlineData("filter", "not(equals(details,null))")] - [InlineData("filter", "equals(details.nested.description,'test')")] - [InlineData("filter", "has(items,equals(label,'work'))")] - [InlineData("filter", "has(details.items,equals(label,'work'))")] - [InlineData("filter", "greaterThan(count(items),'3')")] - [InlineData("filter", "greaterThan(count(details.items),'1')")] - [InlineData("filter", "greaterThan(count(tags),'0')")] - [InlineData("filter", "greaterThan(count(details.numbers),'2')")] - [InlineData("filter", "has(tags)")] - [InlineData("filter", "has(tags,equals(_,'work'))")] - [InlineData("filter", "has(tags,startsWith(_,'w'))")] - [InlineData("filter", "has(tags,contains(_,'o'))")] - [InlineData("filter", "has(tags,endsWith(_,'k'))")] - [InlineData("filter", "has(tags,any(_,'work'))")] - [InlineData("filter", "has(tags,not(equals(_,'spam')))")] - [InlineData("filter", "has(tags,equals(_,null))")] - [InlineData("filter", "has(details.numbers)")] - [InlineData("filter", "has(details.numbers,equals(_,'42'))")] - [InlineData("filter", "has(details.numbers,greaterThan(_,'10'))")] - public void Reader_Read_Compound_Filter_Succeeds(string parameterName, string parameterValue) - { - // Act - _reader.Read(parameterName, parameterValue); - - IReadOnlyCollection constraints = _reader.GetConstraints(); - - // Assert - QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); - value.ToString().Should().Be(parameterValue); - } - - [Theory] - [InlineData("filter", "greaterThan(details^,null)", - "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] - [InlineData("filter", "equals(details.^unknown,'x')", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] - [InlineData("filter", "has(details.numbers,equals(_,^null))", "Function, value between quotes or _ expected.")] - [InlineData("filter", "has(tags,equals(^unknown,'work'))", "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' cannot be used on scalar collection elements.")] - public void Reader_Read_Compound_Filter_Fails(string parameterName, string parameterValue, string errorMessage) - { - // Arrange - var parameterValueSource = new MarkedText(parameterValue, '^'); - - // Act - Action action = () => _reader.Read(parameterName, parameterValueSource.Text); - - // Assert - InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; - exception.ParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - - ErrorObject error = exception.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); - error.Source.Should().NotBeNull(); - error.Source.Parameter.Should().Be(parameterName); - } - - [Fact] - public void CollectionElementValueExpression_Properties_And_Equality() - { - var expr1 = new CollectionElementValueExpression(typeof(string)); - var expr2 = new CollectionElementValueExpression(typeof(string)); - var expr3 = new CollectionElementValueExpression(typeof(int)); - - expr1.ElementType.Should().Be(); - expr1.ToString().Should().Be("_"); - expr1.ToFullString().Should().Be("_"); - expr1.Should().Be(expr2); - expr1.GetHashCode().Should().Be(expr2.GetHashCode()); - expr1.Should().NotBe(expr3); - - Action createWithNull = () => _ = new CollectionElementValueExpression(null!); - createWithNull.Should().ThrowExactly(); - } - - [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(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/QueryStringParameters/CompoundSortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs deleted file mode 100644 index 09915726ce..0000000000 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSortParseTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Net; -using FluentAssertions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Parsing; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; - -public sealed class CompoundSortParseTests -{ - private readonly SortQueryStringParameterReader _reader; - - public CompoundSortParseTests() - { - var options = new JsonApiOptions(); - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - - var request = new JsonApiRequest - { - PrimaryResourceType = resourceGraph.GetResourceType(), - IsCollection = true - }; - - var scopeParser = new QueryStringParameterScopeParser(); - var valueParser = new SortParser(); - _reader = new SortQueryStringParameterReader(scopeParser, valueParser, request, resourceGraph); - } - - [Theory] - [InlineData("sort", "name")] - [InlineData("sort", "details.title")] - [InlineData("sort", "-details.nested.description")] - [InlineData("sort", "count(items)")] - [InlineData("sort", "-count(details.numbers)")] - [InlineData("sort", "count(tags),details.title")] - public void Reader_Read_Compound_Sort_Succeeds(string parameterName, string parameterValue) - { - // Act - _reader.Read(parameterName, parameterValue); - - IReadOnlyCollection constraints = _reader.GetConstraints(); - - // Assert - var value = (SortExpression)constraints.Select(expressionInScope => expressionInScope.Expression).Single(); - value.ToString().Should().Be(parameterValue); - } - - [Theory] - [InlineData("sort", "details^", - "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] - [InlineData("sort", "^tags", - "Field chain on resource type 'compoundModels' 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 'compoundModels' expected.")] - [InlineData("sort", "^items", - "Field chain on resource type 'compoundModels' 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 'compoundModels' expected.")] - [InlineData("sort", "count(details^)", - "Field chain on resource type 'compoundModels' 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 compound type 'CompoundRoot' expected.")] - public void Reader_Read_Compound_Sort_Fails(string parameterName, string parameterValue, string errorMessage) - { - // Arrange - var parameterValueSource = new MarkedText(parameterValue, '^'); - - // Act - Action action = () => _reader.Read(parameterName, parameterValueSource.Text); - - // Assert - InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; - exception.ParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - - ErrorObject error = exception.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified sort is invalid."); - error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); - error.Source.Should().NotBeNull(); - error.Source.Parameter.Should().Be(parameterName); - } - - [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(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/QueryStringParameters/CompoundSparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs deleted file mode 100644 index 407c4f1e15..0000000000 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/CompoundSparseFieldSetParseTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Net; -using FluentAssertions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Parsing; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; - -public sealed class CompoundSparseFieldSetParseTests -{ - private readonly SparseFieldSetQueryStringParameterReader _reader; - - public CompoundSparseFieldSetParseTests() - { - var options = new JsonApiOptions(); - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - - var request = new JsonApiRequest - { - PrimaryResourceType = resourceGraph.GetResourceType(), - IsCollection = true - }; - - var typeParser = new SparseFieldTypeParser(resourceGraph); - var valueParser = new SparseFieldSetParser(); - _reader = new SparseFieldSetQueryStringParameterReader(typeParser, valueParser, request, resourceGraph); - } - - [Theory] - [InlineData("fields[compoundModels]", "name", "compoundModels(name)")] - [InlineData("fields[compoundModels]", "name,details.title", "compoundModels(details.title,name)")] - [InlineData("fields[compoundModels]", "details.nested.description", "compoundModels(details.nested.description)")] - [InlineData("fields[compoundModels]", "details.items.label", "compoundModels(details.items.label)")] - [InlineData("fields[compoundModels]", "details.numbers", "compoundModels(details.numbers)")] - [InlineData("fields[compoundModels]", "tags", "compoundModels(tags)")] - [InlineData("fields[compoundModels]", "details", "compoundModels(details)")] - [InlineData("fields[compoundModels]", "items", "compoundModels(items)")] - [InlineData("fields[compoundModels]", "parent", "compoundModels(parent)")] - public void Reader_Read_Compound_SparseFieldSet_Succeeds(string parameterName, string parameterValue, string expectedValue) - { - // Act - _reader.Read(parameterName, parameterValue); - - IReadOnlyCollection constraints = _reader.GetConstraints(); - - // Assert - QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); - value.ToString().Should().Be(expectedValue); - } - - [Theory] - [InlineData("fields[compoundModels]", "details.^unknown", "Field 'unknown' does not exist on compound type 'CompoundRoot'.")] - [InlineData("fields[compoundModels]", "parent.^name", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes, followed by a field. " + - "End of field chain expected.")] - [InlineData("fields[compoundModels]", "tags.^something", - "Field chain on resource type 'compoundModels' failed to match the pattern: zero or more compound attributes, followed by a field. " + - "End of field chain expected.")] - public void Reader_Read_Compound_SparseFieldSet_Fails(string parameterName, string parameterValue, string errorMessage) - { - // Arrange - var parameterValueSource = new MarkedText(parameterValue, '^'); - - // Act - Action action = () => _reader.Read(parameterName, parameterValueSource.Text); - - // Assert - InvalidQueryStringParameterException exception = action.Should().ThrowExactly().Which; - exception.ParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - - ErrorObject error = exception.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified fieldset is invalid."); - error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); - error.Source.Should().NotBeNull(); - error.Source.Parameter.Should().Be(parameterName); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class CompoundModel : Identifiable - { - [Attr] - public string? Name { get; set; } - - [HasOne] - public CompoundModel? Parent { 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(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/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index 4487f8cf51..5d69ead806 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -110,14 +110,29 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "equals^'", "Unexpected ' outside 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(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,'')", "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(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' cannot be used on scalar collection elements.")] + [InlineData("filter", "equals(title,^_)", "Function, field name, value between quotes or null expected.")] + [InlineData("filter", "equals(owner,^'some')", "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.")] @@ -126,8 +141,8 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter[posts]", "contains(author^,null)", "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')", "Field name expected.")] - [InlineData("filter", "any(^'a','b','c')", "Field name 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(title^)", ", 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,7 +203,22 @@ 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.userName,author.displayName)", "posts")] @@ -232,7 +262,7 @@ 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!); @@ -243,39 +273,68 @@ public void Throws_When_ResourceType_Scope_Not_Disposed() } [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 or compound 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. - _ = InScopeOfContainer(ContainerInScope); + _ = 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 container in scope yet. - _ = ContainerInScope; + // Fails because there is no scope active yet. + _ = CurrentScope; base.Tokenize(source); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index 030135e315..0d853c71a9 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -36,8 +36,8 @@ public LegacyFilterParseTests() [InlineData("filter[]", "Expected field name between brackets 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 or compound non-collection attributes, followed by a to-one relationship or a non-collection attribute. " + - "To-one relationship or non-collection 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 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) { From 946cc791439be75807fdb748b9111879342dda40 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:35:09 +0100 Subject: [PATCH 58/63] Revert adding public unused constants in FieldChainPattern --- .../QueryStrings/FieldChains/FieldChainPattern.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs index d4172e0556..6296c234c0 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -11,16 +11,6 @@ namespace JsonApiDotNetCore.QueryStrings.FieldChains; [PublicAPI] public sealed class FieldChainPattern { - public const char ToManyRelationshipToken = 'M'; - public const char ToOneRelationshipToken = 'O'; - public const char RelationshipToken = 'R'; - public const char ScalarAttributeToken = 'S'; - public const char CompoundAttributeToken = 'C'; - public const char ScalarCollectionToken = 'L'; - public const char CompoundCollectionToken = 'Q'; - public const char AttributeToken = 'A'; - public const char FieldToken = 'F'; - /// /// Gets the set of possible resource field types. /// From 743b2b87a1ded1d32aa613655fc8637e0ec21cf6 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:59:37 +0100 Subject: [PATCH 59/63] Fix crash in ScopesAuthorizationFilter when used with compound/collection attributes --- .../Authorization/Scopes/ScopesAuthorizationFilter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs index ce28253b30..826934702a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -88,9 +88,9 @@ public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpres { _authScopeSet.Include(relationship, Permission.Read); } - else + else if (field.ContainingType is ResourceType resourceType) { - _authScopeSet.Include((ResourceType)field.ContainingType, Permission.Read); + _authScopeSet.Include(resourceType, Permission.Read); } } From 2e8c455acc36f057714acc382269d863daf087a4 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:29:58 +0100 Subject: [PATCH 60/63] Query string parsers: provide API to render set of expected parse choices, improve error messages, allow comparing 2 to-one relationships for equality --- .../Queries/Parsing/FilterParser.cs | 295 +++++++++--------- .../Queries/Parsing/IncludeParser.cs | 4 +- .../Queries/Parsing/PaginationParser.cs | 51 ++- .../Queries/Parsing/QueryExpressionParser.cs | 118 ++++--- .../Parsing/QueryParseChoiceFormatter.cs | 149 +++++++++ .../Queries/Parsing/QueryParseChoices.cs | 27 ++ .../Queries/Parsing/QueryParseException.cs | 5 + .../QueryStringParameterScopeParser.cs | 8 +- .../Queries/Parsing/QueryTokenizer.cs | 4 +- .../Queries/Parsing/SortParser.cs | 61 ++-- .../Queries/Parsing/SparseFieldSetParser.cs | 6 +- .../Queries/Parsing/SparseFieldTypeParser.cs | 4 +- .../FilterQueryStringParameterReader.cs | 35 ++- .../LegacyFilterNotationConverter.cs | 4 +- .../PaginationQueryStringParameterReader.cs | 6 +- .../QueryStringParameterReader.cs | 5 +- .../QueryStrings/QueryStringReader.cs | 5 +- .../SortQueryStringParameterReader.cs | 2 +- .../QueryStrings/AtomicQueryStringTests.cs | 12 +- .../AttributeTypeFilterTests.cs | 2 +- .../AttributeTypes/TypeAwareFilterParser.cs | 2 +- .../CompoundAttributesValidationTests.cs | 2 +- .../Decrypt/DecryptFilterParseTests.cs | 2 +- .../Decrypt/DecryptFilterParser.cs | 10 +- .../IsUpperCaseFilterParseTests.cs | 2 +- .../IsUpperCase/IsUpperCaseFilterParser.cs | 10 +- .../StringLength/LengthFilterParseTests.cs | 2 +- .../StringLength/LengthFilterParser.cs | 10 +- .../StringLength/LengthSortParseTests.cs | 2 +- .../StringLength/LengthSortParser.cs | 8 +- .../Sum/SumFilterParseTests.cs | 8 +- .../CustomFunctions/Sum/SumFilterParser.cs | 24 +- .../TimeOffset/TimeOffsetFilterParser.cs | 4 +- .../TimeOffset/TimeOffsetTests.cs | 4 +- .../Filtering/FilterDepthTests.cs | 2 +- .../Filtering/FilterOperatorTests.cs | 6 +- .../QueryStrings/Filtering/FilterTests.cs | 60 ++++ .../QueryStrings/Includes/IncludeTests.cs | 6 +- .../PaginationWithTotalCountTests.cs | 9 +- .../RangeValidationWithMaximumTests.cs | 4 +- .../QueryStrings/QueryStringTests.cs | 2 +- .../QueryStrings/Sorting/SortTests.cs | 27 +- .../SparseFieldSets/SparseFieldSetTests.cs | 6 +- .../DisableQueryStringTests.cs | 6 +- .../QueryStringParameters/FilterParseTests.cs | 19 +- .../LegacyFilterParseTests.cs | 13 +- .../PaginationParseTests.cs | 24 +- .../QueryParseChoiceFormatterTests.cs | 120 +++++++ .../QueryStringParameters/SortParseTests.cs | 4 +- 49 files changed, 834 insertions(+), 367 deletions(-) create mode 100644 src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs create mode 100644 src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/QueryParseChoiceFormatterTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index d64f51d76c..e5416f9183 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -37,6 +37,14 @@ public class FilterParser : QueryExpressionParser, IFilterParser private readonly IResourceFactory _resourceFactory; private readonly Stack _scopeStack = new(); + protected QueryParseChoices FieldOrElementChoice => + CurrentScope switch + { + ScalarCollectionElementFilterScope => QueryParseChoices.Underscore, + AttributeContainerFilterScope => QueryParseChoices.FieldName, + _ => throw new UnreachableException() + }; + /// /// Gets the current filter scope. Call or to temporarily change the current /// scope. @@ -90,21 +98,15 @@ 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) { @@ -115,8 +117,7 @@ private CountExpression ParseCount() EatText(Keywords.Count); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, - FieldChainPatternMatchOptions.None, containerScope.Container, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, containerScope.Container); EatSingleCharacterToken(TokenKind.CloseParen); @@ -168,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() @@ -200,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); } @@ -242,34 +241,30 @@ private QueryExpression ParseComparisonLeftTerm(bool isEqualityComparison) return ParseFunction(); } - switch (CurrentScope) + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) { - case ScalarCollectionElementFilterScope collectionScope: - { - ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - if (elementValue != null) - { - return elementValue; - } - - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or _ expected.", position); + if (elementValue != null) + { + return elementValue; } - case AttributeContainerFilterScope containerScope: + } + 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, FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); - } - default: - { - throw new UnreachableException(); + return ParseFieldChain(pattern, containerScope.Container); } } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); } /// @@ -279,9 +274,8 @@ private QueryExpression ParseComparisonLeftTerm(bool isEqualityComparison) { ArgumentNullException.ThrowIfNull(collectionScope); - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Underscore) + if (TryEatSingleCharacterToken(TokenKind.Underscore)) { - EatSingleCharacterToken(TokenKind.Underscore); return new ScalarCollectionElementValueExpression(collectionScope.ElementType); } @@ -294,7 +288,12 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm, bool { ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; - if (leftLastField is HasOneAttribute or AttrAttribute { IsCompound: true } or AttrAttribute { IsCollection: true }) + if (leftLastField is HasOneAttribute) + { + return ParseToOneRelationshipComparisonRightTerm(); + } + + if (leftLastField is AttrAttribute { IsCompound: true } or AttrAttribute { IsCollection: true }) { return ParseNull(); } @@ -320,21 +319,44 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm, bool throw new UnreachableException(); } + private QueryExpression ParseToOneRelationshipComparisonRightTerm() + { + if (CurrentScope is not AttributeContainerFilterScope containerScope) + { + throw new UnreachableException(); + } + + 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 records. + // 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); - string errorMessage = (CurrentScope, allowNull) switch + QueryParseChoices rightTermChoices = QueryParseChoices.Function | FieldOrElementChoice | QueryParseChoices.QuotedText; + + if (allowNull) { - (ScalarCollectionElementFilterScope, true) => "Function, value between quotes, _ or null expected.", - (ScalarCollectionElementFilterScope, false) => "Function, value between quotes or _ expected.", - (AttributeContainerFilterScope, true) => "Function, field name, value between quotes or null expected.", - (AttributeContainerFilterScope, false) => "Function, field name or value between quotes expected.", - _ => throw new UnreachableException() - }; + rightTermChoices |= QueryParseChoices.Null; + } if (TokenStack.TryPeek(out Token? nextToken)) { @@ -356,19 +378,19 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal } } - if (nextToken.Kind == TokenKind.Text) + if (IsNullConstantToken(nextToken)) { - if (nextToken.Value == Keywords.Null) + if (!allowNull) { - if (!allowNull) - { - throw new QueryParseException(errorMessage, nextToken.Position); - } - - TokenStack.Pop(); - return NullConstantExpression.Instance; + throw new QueryParseException(rightTermChoices, nextToken.Position); } + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (nextToken.Kind == TokenKind.Text) + { if (IsFunction(nextToken.Value!)) { return ParseFunction(); @@ -376,14 +398,13 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal if (CurrentScope is AttributeContainerFilterScope containerScope) { - return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.None, - containerScope.Container, errorMessage); + 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) @@ -414,53 +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; } - switch (CurrentScope) + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) { - case ScalarCollectionElementFilterScope collectionScope: - { - int position = GetNextTokenPositionOrEnd(); - ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); + int elementPosition = GetNextTokenPositionOrEnd(); + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - if (elementValue != null) + if (elementValue != null) + { + if (elementValue.ElementType != typeof(string)) { - if (elementValue.ElementType != typeof(string)) - { - throw new QueryParseException("Element of type 'String' expected.", position); - } - - return elementValue; + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(elementValue.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", elementPosition); } - throw new QueryParseException("Function or _ expected.", position); + return elementValue; } - case AttributeContainerFilterScope containerScope: + + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, elementPosition); + } + + if (CurrentScope is AttributeContainerFilterScope containerScope) + { + if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) { int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); + containerScope.Container); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; if (targetAttribute.Property.PropertyType != typeof(string)) { - int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); - throw new QueryParseException("Attribute of type 'String' expected.", position); + 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; } - default: - { - throw new UnreachableException(); - } } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); } protected virtual AnyExpression ParseAny() @@ -478,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); } @@ -499,40 +523,36 @@ protected virtual AnyExpression ParseAny() { FunctionExpression targetFunction = ParseFunction(); - Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); - return (targetFunction, functionConverterFactory); + Func converterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); + return (targetFunction, converterFactory); } - switch (CurrentScope) + if (CurrentScope is ScalarCollectionElementFilterScope collectionScope) { - case ScalarCollectionElementFilterScope collectionScope: - { - ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - - if (elementValue != null) - { - Func functionConverterFactory = () => GetConstantValueConverterForType(elementValue.ElementType); - return (elementValue, functionConverterFactory); - } + ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope); - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function or _ expected.", position); + if (elementValue != null) + { + Func converterFactory = () => GetConstantValueConverterForType(elementValue.ElementType); + return (elementValue, converterFactory); } - case AttributeContainerFilterScope containerScope: + } + else if (CurrentScope is AttributeContainerFilterScope containerScope) + { + if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token)) { ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, "Function or field name expected."); + containerScope.Container); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; - Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); - return (targetAttributeChain, attributeConverterFactory); - } - default: - { - throw new UnreachableException(); + Func converterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); + return (targetAttributeChain, converterFactory); } } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position); } protected virtual HasExpression ParseHas() @@ -546,15 +566,12 @@ protected virtual HasExpression ParseHas() EatText(Keywords.Has); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, - FieldChainPatternMatchOptions.None, containerScope.Container, 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); - ResourceFieldAttribute lastField = targetCollection.Fields[^1]; if (lastField is HasManyAttribute hasManyRelationship) @@ -589,35 +606,16 @@ protected virtual HasExpression ParseHas() protected virtual IsTypeExpression ParseIsType() { - ResourceType resourceTypeInScope; - - switch (CurrentScope) + if (CurrentScope is not AttributeContainerFilterScope { Container: ResourceType resourceTypeInScope }) { - case AttributeContainerFilterScope { Container: ResourceType resourceType }: - { - resourceTypeInScope = resourceType; - break; - } - case AttributeContainerFilterScope: - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'isType' cannot be used on compound types.", position); - } - case ScalarCollectionElementFilterScope: - { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'isType' cannot be used on scalar collection elements.", position); - } - default: - { - throw new UnreachableException(); - } + 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(resourceTypeInScope); + ResourceFieldChainExpression? targetToOneRelationship = TryParseCommaSeparatedSequenceOfToOneRelationshipChains(resourceTypeInScope); EatSingleCharacterToken(TokenKind.Comma); @@ -631,14 +629,23 @@ protected virtual IsTypeExpression ParseIsType() return new IsTypeExpression(targetToOneRelationship, derivedType, child); } - private ResourceFieldChainExpression? TryParseToOneRelationshipChain(ResourceType resourceTypeInScope) + 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, "To-one relationship name or , expected."); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.Comma, position); } private ResourceType ParseDerivedType(ResourceType baseType) @@ -651,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) @@ -690,10 +697,8 @@ 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 (InScopeOfContainer(derivedType)) { filter = ParseFilter(); @@ -703,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) @@ -713,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) @@ -741,7 +748,7 @@ 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); } }; } 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 ac2e06a464..6905bed614 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,18 +69,26 @@ 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, IAttributeContainer container, - string? alternativeErrorMessage) + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, IAttributeContainer container, + FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None) { ArgumentNullException.ThrowIfNull(pattern); ArgumentNullException.ThrowIfNull(container); int startPosition = GetNextTokenPositionOrEnd(); - string path = EatFieldChain(alternativeErrorMessage); + string path = EatFieldChain(); PatternMatchResult result = pattern.Match(path, container, options); if (!result.IsSuccess) @@ -106,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(); @@ -114,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 @@ -130,7 +137,7 @@ private string EatFieldChain(string? alternativeErrorMessage) } else { - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position); + throw new QueryParseException(QueryParseChoices.FieldName, position); } } } @@ -139,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); @@ -151,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) { @@ -168,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. @@ -208,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); } } @@ -233,7 +275,7 @@ protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(Reso isAtStart = false; } - ParseRelationshipChain(treeRoot, false, null); + ParseRelationshipChain(treeRoot, false, true); } return treeRoot.ToExpression(); @@ -242,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: @@ -280,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(); @@ -300,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 = []; @@ -323,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(); } @@ -398,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); } } 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 c1a6d539e2..bdee55584c 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -70,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; @@ -109,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); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs index e01bedba5d..744141caf9 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -11,8 +11,6 @@ namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public class SortParser : QueryExpressionParser, ISortParser { - private bool _isInsideCount; - /// public SortExpression Parse(string source, ResourceType resourceType) { @@ -51,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. @@ -84,21 +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!)) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text }) { - target = ParseFunction(resourceType); - } - else - { - string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; + 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); - target = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, - errorMessage); + return new SortElementExpression(fieldChainTarget, isAscending); + } } - return new SortElementExpression(target, isAscending); + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(choices, position); } protected virtual bool IsFunction(string name) @@ -124,27 +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); - _isInsideCount = true; - - ResourceFieldChainExpression targetCollection; - - try - { - targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, FieldChainPatternMatchOptions.AllowDerivedTypes, - resourceType, null); - } - finally - { - _isInsideCount = false; - } + ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, resourceType, + FieldChainPatternMatchOptions.AllowDerivedTypes); EatSingleCharacterToken(TokenKind.CloseParen); @@ -156,9 +148,8 @@ protected override void ValidateField(ResourceFieldAttribute field, int position ArgumentNullException.ThrowIfNull(field); // 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]*A' ensures only leaf scalar attributes are evaluated. - if (!_isInsideCount && field is AttrAttribute { IsCompound: false, IsCollection: false } attribute && - !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + // 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 d5fdf25aae..4068dab63f 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -38,9 +38,7 @@ public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser EatSingleCharacterToken(TokenKind.Comma); } - ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.CompoundOrCompoundCollectionChainEndingInField, - FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); - + ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.CompoundOrCompoundCollectionChainEndingInField, resourceType); fieldSetBuilder.Add(nextChain); } @@ -54,7 +52,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/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index 5c1fffb8b5..b1ec5f2c7f 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; 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..74f68dfcb9 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); } } 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/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 8bfe85a9e0..9fed94918b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -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/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/AttributeTypes/AttributeTypeFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs index 365d5fdc51..2d1b001c1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AttributeTypes/AttributeTypeFilterTests.cs @@ -352,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/CompoundAttributes/CompoundAttributesValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs index 2be1420152..5c3f3808e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesValidationTests.cs @@ -157,7 +157,7 @@ public async Task Cannot_use_isType_inside_compound_attribute() 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' cannot be used on compound types"); + error.Detail.Should().Contain("Function 'isType' can only be used on resource types"); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs index 26c37430da..0a995249cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs @@ -38,7 +38,7 @@ 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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs index 98707c21e5..ff37256488 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs @@ -54,12 +54,13 @@ private ScalarCollectionElementValueExpression ParseScalarCollectionElement(Scal if (elementValue == null) { - throw new QueryParseException("_ expected.", position); + throw new QueryParseException(QueryParseChoices.Underscore, position); } if (collectionScope.ElementType != typeof(string)) { - throw new QueryParseException("Element of type 'String' expected.", position); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); } return elementValue; @@ -70,14 +71,15 @@ private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilt int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, null); + 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); } return targetAttributeChain; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs index ecbb6ac004..d149a3a619 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -38,7 +38,7 @@ 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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs index 89c9c7b959..0cf91f3d34 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -44,12 +44,13 @@ private ScalarCollectionElementValueExpression ParseScalarCollectionElement(Scal if (elementValue == null) { - throw new QueryParseException("_ expected.", position); + throw new QueryParseException(QueryParseChoices.Underscore, position); } if (collectionScope.ElementType != typeof(string)) { - throw new QueryParseException("Element of type 'String' expected.", position); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); } return elementValue; @@ -60,14 +61,15 @@ private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilt int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, null); + 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); } return targetAttributeChain; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs index ffd82c8762..ed36428808 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -38,7 +38,7 @@ 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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs index 3787935a05..5dc534d9d2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -54,12 +54,13 @@ private ScalarCollectionElementValueExpression ParseScalarCollectionElement(Scal if (elementValue == null) { - throw new QueryParseException("_ expected.", position); + throw new QueryParseException(QueryParseChoices.Underscore, position); } if (collectionScope.ElementType != typeof(string)) { - throw new QueryParseException("Element of type 'String' expected.", position); + string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(collectionScope.ElementType); + throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", position); } return elementValue; @@ -70,14 +71,15 @@ private ResourceFieldChainExpression ParseTargetAttribute(AttributeContainerFilt int chainStartPosition = GetNextTokenPositionOrEnd(); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, null); + 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); } return targetAttributeChain; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs index 9e55ecb901..d888792b1c 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 diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs index 317be02541..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.ToOneOrCompoundChainEndingInScalarAttribute, - 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/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs index 299c131ca0..39b16b0b6f 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. " + diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs index 9e6a5879ae..c2c73878a1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -37,23 +37,28 @@ private SumExpression ParseSum() { if (CurrentScope is not AttributeContainerFilterScope containerScope) { - int position = GetNextTokenPositionOrEnd(); - throw new QueryParseException("Function 'sum' cannot be used on scalar collection elements.", position); + 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, - containerScope.Container, "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) @@ -85,8 +90,7 @@ private QueryExpression ParseSumSelector() return function; } - ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, - FieldChainPatternMatchOptions.None, containerScope.Container, null); + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, containerScope.Container); var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; 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 b0ab58474c..026449e695 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -20,6 +20,7 @@ public FilterTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.App.Services.GetRequiredService(); options.EnableLegacyFilterNotation = false; @@ -489,4 +490,63 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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/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/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/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index 5d69ead806..05a4130e03 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -97,7 +97,7 @@ 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 or compound non-collection attributes, followed by a scalar non-collection attribute. " + @@ -107,15 +107,15 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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, 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,'')", "Function or field name expected.")] - [InlineData("filter", "equals(owner.displayName.^,'')", "Function or field name expected.")] - [InlineData("filter", "equals(title,'^)", "' 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.")] @@ -126,9 +126,9 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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' 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')", "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.")] @@ -136,7 +136,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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 or compound non-collection attributes, followed by a scalar non-collection attribute. " + @@ -144,7 +144,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [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 or compound non-collection attributes, followed by a scalar non-collection attribute. " + @@ -221,6 +221,7 @@ public void Reader_Read_ParameterValue_Fails(string parameterName, string parame [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")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index 0d853c71a9..4144ac2cac 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -31,9 +31,9 @@ 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 or compound non-collection attributes, followed by a to-one relationship or an attribute. " + @@ -59,9 +59,12 @@ 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 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.")] diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index 9841e0fc47..10038f692e 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'.")] 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 0cf77bfbc2..aaad3480ff 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -94,7 +94,7 @@ 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 or compound non-collection attributes, followed by a scalar non-collection attribute. " + @@ -114,7 +114,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe "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.")] From d6af3b2198c8cc08aacf764a3eb98019b98612ff Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:17:18 +0100 Subject: [PATCH 61/63] Clarify docs --- .../Configuration/CompoundType.cs | 6 +----- .../Configuration/IAttributeContainer.cs | 4 ++-- .../Resources/Annotations/AttrAttribute.cs | 2 +- .../Annotations/RelationshipAttribute.cs | 2 +- .../Queries/Expressions/QueryExpression.cs | 21 +++++++++++++++++++ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs index a8bb811c50..cce472103c 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs @@ -4,12 +4,8 @@ namespace JsonApiDotNetCore.Configuration; /// -/// Metadata about the shape of an embedded compound value object in the resource graph. +/// Metadata about the shape of a compound JSON:API attribute in the resource graph. /// -/// -/// Compound types used with sparse fieldsets or request deserialization must provide an accessible parameterless constructor (can be non-public) to -/// support member initialization expressions and object instantiation. -/// [PublicAPI] public sealed class CompoundType : IAttributeContainer { diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs index 2dd6bd0be7..5f4e8f567d 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Configuration; /// -/// Represents a schema element (such as a resource type or a compound type) that contains JSON:API attributes. +/// Metadata about the shape of a type that contains JSON:API attributes. /// [PublicAPI] public interface IAttributeContainer @@ -15,7 +15,7 @@ public interface IAttributeContainer Type ClrType { get; } /// - /// The attributes contained in this schema element. + /// The attributes in this container. /// IReadOnlyCollection Attributes { get; } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 21a22ec398..30e8abe857 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -45,7 +45,7 @@ public AttrCapabilities Capabilities public bool IsCompound { get; set; } /// - /// The schema of the nested compound type when is true; otherwise null. + /// Provides access to nested attributes when is true; otherwise null. /// public CompoundType? CompoundType { get; internal set; } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index bac14e3f1f..c8679585da 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -41,7 +41,7 @@ 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 => (ResourceType)ContainingType; 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(); } From cc3e7b6cde5e1b4adb638cd87a141c5081329c50 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:08:48 +0100 Subject: [PATCH 62/63] Fix conditional assertion --- .../Endpoints/MinimalApis/MinimalApiTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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(); From be10f73f1462b9d7ef383a6db208ec4dec8347bb Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:02:24 +0100 Subject: [PATCH 63/63] Refactorings to support sparse fieldsets with compound/collection attributes - Rename SparseFieldSetCache to SparseFieldSetEvaluator - Refactor ExpressionInScope into derived types for relationship chain (filter/sort) and resource type (fields) - Replace SparseFieldTableExpression AST node with SparseFieldSetElementExpression (this is a tree now) - Don't invoke redundant resource definition callbacks --- .../SerializationBenchmarkBase.cs | 4 +- docs/api/index.md | 2 +- docs/usage/faq.md | 2 +- .../Services/InMemoryResourceService.cs | 1 + .../AtomicOperations/OperationsProcessor.cs | 10 +- src/JsonApiDotNetCore/CollectionExtensions.cs | 3 +- .../JsonApiApplicationBuilder.cs | 2 +- .../Queries/EvaluatedIncludeCache.cs | 1 + .../Queries/ExpressionInScope.cs | 19 +- .../Queries/Expressions/IncludeExpression.cs | 7 + .../Expressions/QueryExpressionRewriter.cs | 56 ++-- .../Expressions/QueryExpressionVisitor.cs | 4 +- .../SparseFieldSetElementExpression.cs | 120 +++++++ .../Expressions/SparseFieldSetExpression.cs | 52 ++- .../SparseFieldSetExpressionExtensions.cs | 105 +++--- .../Expressions/SparseFieldTableExpression.cs | 90 ----- .../Expressions/SparseFieldTreeBuilder.cs | 168 ++++++++++ .../Queries/IQueryLayerComposer.cs | 7 - .../Queries/ISparseFieldSetCache.cs | 52 --- .../Queries/ISparseFieldSetEvaluator.cs | 32 ++ .../Queries/Parsing/ISparseFieldSetParser.cs | 2 +- .../Queries/Parsing/QueryExpressionParser.cs | 2 +- .../Queries/Parsing/SparseFieldSetParser.cs | 20 +- src/JsonApiDotNetCore/Queries/QueryLayer.cs | 80 +++++ .../Queries/QueryLayerComposer.cs | 311 ++++++++---------- .../QueryableBuilding/SelectClauseBuilder.cs | 39 ++- .../RelationshipChainExpressionInScope.cs | 20 ++ .../Queries/ResourceTypeExpressionInScope.cs | 27 ++ .../Queries/SparseFieldSetCache.cs | 162 --------- .../Queries/SparseFieldSetEvaluator.cs | 189 +++++++++++ .../FilterQueryStringParameterReader.cs | 4 +- .../IncludeQueryStringParameterReader.cs | 4 +- .../PaginationQueryStringParameterReader.cs | 4 +- ...ourceDefinitionQueryableParameterReader.cs | 2 +- .../SortQueryStringParameterReader.cs | 2 +- ...parseFieldSetQueryStringParameterReader.cs | 15 +- .../EntityFrameworkCoreRepository.cs | 1 + .../Response/ResponseModelAdapter.cs | 68 ++-- ...icSparseFieldSetResourceDefinitionTests.cs | 4 - .../Scopes/ScopesAuthorizationFilter.cs | 14 + .../CompoundAttributesFakers.cs | 3 +- .../CompoundAttributesSparseFieldSetTests.cs | 214 +++++++++--- .../CompoundAttributes/Entities.cs | 4 + .../Decrypt/DecryptFilterParseTests.cs | 3 +- .../IsUpperCaseFilterParseTests.cs | 3 +- .../StringLength/LengthFilterParseTests.cs | 3 +- .../StringLength/LengthSortParseTests.cs | 3 +- .../Sum/SumFilterParseTests.cs | 3 +- .../Reading/ResourceDefinitionReadTests.cs | 33 +- .../Queries/QueryExpressionRewriterTests.cs | 37 +-- .../UnitTests/Queries/QueryExpressionTests.cs | 28 +- ...SparseFieldSetExpressionExtensionsTests.cs | 218 ++++++++++-- .../TestableQueryExpressionRewriter.cs | 8 +- .../QueryStringParameters/FilterParseTests.cs | 4 +- .../IncludeParseTests.cs | 3 +- .../LegacyFilterParseTests.cs | 3 +- .../PaginationParseTests.cs | 3 +- .../QueryStringParameters/SortParseTests.cs | 3 +- .../SparseFieldSetParseTests.cs | 21 +- .../QueryableBuilding/ClauseBuilderTests.cs | 32 ++ .../Response/IncompleteResourceGraphTests.cs | 4 +- .../Response/ResponseModelAdapterTests.cs | 4 +- 62 files changed, 1459 insertions(+), 885 deletions(-) create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs delete mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs create mode 100644 src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs create mode 100644 src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/ResourceTypeExpressionInScope.cs delete mode 100644 src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs create mode 100644 src/JsonApiDotNetCore/Queries/SparseFieldSetEvaluator.cs 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/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/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/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/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/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index a3aecc3408..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 @@ -180,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(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) - { - if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) - { - newTable[resourceType] = newSparseFieldSet; - } - } + IImmutableDictionary newElements = VisitDictionary(expression.Elements, argument); - 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) @@ -241,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; } @@ -294,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 ae7a57f507..1c848a7288 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -99,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/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 4e777aa60f..9ce237ec09 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -9,52 +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. + /// Indicates not to include any fields. /// - public IImmutableSet Fields { get; } + public static readonly SparseFieldSetExpression Empty = new(); /// - /// The set of JSON:API field chains to include. + /// The JSON:API fields included in this sparse fieldset tree, keyed by field (attribute or relationship). /// - public IImmutableSet FieldChains { get; } + public IImmutableDictionary Elements { get; } - public SparseFieldSetExpression(IImmutableSet fields) + public SparseFieldSetExpression(IImmutableDictionary elements) { - ArgumentGuard.NotNullNorEmpty(fields); + ArgumentGuard.NotNullNorEmpty(elements); + + Elements = elements; + } - Fields = fields; - FieldChains = fields.Select(field => new ResourceFieldChainExpression(ImmutableArray.Create(field))).ToImmutableHashSet(); + private SparseFieldSetExpression() + { + Elements = ImmutableDictionary.Empty; } - public SparseFieldSetExpression(IImmutableSet fieldChains) + internal static SparseFieldSetExpression Create(IImmutableDictionary elements) { - ArgumentGuard.NotNullNorEmpty(fieldChains); + ArgumentNullException.ThrowIfNull(elements); - FieldChains = fieldChains; - Fields = fieldChains.Select(chain => chain.Fields[0]).ToImmutableHashSet(); + 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(',', FieldChains.Select(chain => chain.ToString()).Order()); + return InnerToString(false); } + /// public override string ToFullString() { - return string.Join(',', FieldChains.Select(chain => $"{chain.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)) @@ -69,16 +85,18 @@ public override bool Equals(object? obj) var other = (SparseFieldSetExpression)obj; - return FieldChains.SetEquals(other.FieldChains); + return Elements.DictionaryEquals(other.Elements); } + /// public override int GetHashCode() { var hashCode = new HashCode(); - foreach (ResourceFieldChainExpression chain in FieldChains) + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in Elements.OrderBy(pair => pair.Key.PublicName)) { - hashCode.Add(chain); + 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 99c0e39521..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,37 +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)) { - newSparseFieldSet = IncludeFieldChain(newSparseFieldSet, chain); + builder.IncludeChain(chain.Fields); } - return newSparseFieldSet; + return builder.ToExpression(); } - public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field) { - ArgumentNullException.ThrowIfNull(fieldChain); + ArgumentNullException.ThrowIfNull(field); - return IncludeFieldChain(sparseFieldSet, fieldChain); - } + if (sparseFieldSet == null) + { + return null; + } - private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) - { - return IncludeFieldChain(sparseFieldSet, new ResourceFieldChainExpression(fieldToInclude)); + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.IncludeChain([field]); + return builder.ToExpression(); } - private static SparseFieldSetExpression? IncludeFieldChain(SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression chainToInclude) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) { - if (sparseFieldSet == null || sparseFieldSet.FieldChains.Contains(chainToInclude)) + ArgumentNullException.ThrowIfNull(fieldChain); + + if (sparseFieldSet == null) { - return sparseFieldSet; + return null; } - IImmutableSet newFieldChains = sparseFieldSet.FieldChains.Add(chainToInclude); - return new SparseFieldSetExpression(newFieldChains); + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); + builder.IncludeChain(fieldChain.Fields); + return builder.ToExpression(); } public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, @@ -57,66 +66,50 @@ public static class SparseFieldSetExpressionExtensions ArgumentNullException.ThrowIfNull(fieldSelector); ArgumentNullException.ThrowIfNull(resourceGraph); - SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - - foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) - { - newSparseFieldSet = ExcludeFieldChain(newSparseFieldSet, chain); - } - - return newSparseFieldSet; - } - - public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain) - { - ArgumentNullException.ThrowIfNull(fieldChain); - - return ExcludeFieldChain(sparseFieldSet, fieldChain); - } - - private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) - { - return ExcludeFieldChain(sparseFieldSet, new ResourceFieldChainExpression(fieldToExclude)); - } - - private static SparseFieldSetExpression? ExcludeFieldChain(SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression chainToExclude) - { - // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. + // 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; } - ImmutableHashSet newFieldChains = sparseFieldSet.FieldChains - .Where(chain => !IsPrefixOrEqual(chainToExclude, chain)).ToImmutableHashSet(); + SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet); - if (newFieldChains.Count == sparseFieldSet.FieldChains.Count) + foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector)) { - return sparseFieldSet; + builder.ExcludeChain(chain.Fields); } - return newFieldChains.IsEmpty ? null : new SparseFieldSetExpression(newFieldChains); + return builder.ToExpression(); } - private static bool IsPrefixOrEqual(ResourceFieldChainExpression prefix, ResourceFieldChainExpression target) + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field) { - if (prefix.Fields.Count > target.Fields.Count) + ArgumentNullException.ThrowIfNull(field); + + if (sparseFieldSet == null) { - return false; + return null; } - for (int index = 0; index < prefix.Fields.Count; index++) + 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) { - if (!prefix.Fields[index].Equals(target.Fields[index])) - { - return false; - } + return null; } - return true; + 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/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 3220bbf370..0000000000 --- a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -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. -/// -[PublicAPI] -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); - - /// - /// Gets the sparse fieldset expression to retrieve from the underlying data store, or null to retrieve all fields. - /// - SparseFieldSetExpression? GetSparseFieldSetExpressionForQuery(ResourceType resourceType); - - /// - /// Gets the evaluated sparse fieldset expression to serialize into the response body. - /// - SparseFieldSetExpression GetSparseFieldSetExpressionForSerializer(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/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/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs index 6905bed614..b57e8a31fa 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -496,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/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs index 4068dab63f..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,37 +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.CompoundOrCompoundCollectionChainEndingInField, resourceType); - fieldSetBuilder.Add(nextChain); + treeBuilder.IncludeChain(nextChain.Fields); } - return fieldSetBuilder.Count > 0 ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; + return treeBuilder.ToExpression(); } protected override void ValidateField(ResourceFieldAttribute field, int position) 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 4cf5a96b98..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,47 +593,36 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection elements) + { + foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in elements) + { + if (field is AttrAttribute attr) { - if (chain.Fields.Count == 1) - { - if (chain.Fields[0] is AttrAttribute singleAttr) - { - selectors.IncludeAttribute(singleAttr); - } - } - else + selectors.IncludeAttribute(attr); + + if (element.Children.Count > 0) { - FieldSelectors currentSelectors = selectors; - - for (int i = 0; i < chain.Fields.Count - 1; i++) - { - var parentAttr = (AttrAttribute)chain.Fields[i]; - currentSelectors.IncludeAttribute(parentAttr); - currentSelectors = currentSelectors.GetOrCreateChildSelectors(parentAttr); - } - - if (chain.Fields[^1] is AttrAttribute leafAttr) - { - currentSelectors.IncludeAttribute(leafAttr); - } + FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(attr); + PopulateSelectors(childSelectors, element.Children); } } - - AttrAttribute idAttribute = GetIdAttribute(nextType); - selectors.IncludeAttribute(idAttribute); } - - return selection.IsEmpty ? null : selection; } private static AttrAttribute GetIdAttribute(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index a92a9450f4..1ba0b86933 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -238,16 +238,19 @@ private ConditionalExpression CreateCompoundMemberInit(FieldSelectors childSelec Type compoundClrType = propertyAccess.Type; var assignments = new List(); - foreach ((ResourceFieldAttribute resourceField, _) in childSelectors) + if (childSelectors.ContainsReadOnlyAttribute) { - if (resourceField is AttrAttribute childAttr && childAttr.Property.SetMethod != null) + // 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, childAttr.Property); - FieldSelectors? grandchildSelectors = childSelectors.GetChildSelectors(childAttr); + 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.IsCollection) + if (grandchildSelectors is { IsEmpty: false } && childAttr is not { IsCollection: true }) { childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context); } @@ -256,7 +259,31 @@ private ConditionalExpression CreateCompoundMemberInit(FieldSelectors childSelec childRightHandSide = childPropertyAccess; } - assignments.Add(Expression.Bind(childAttr.Property, childRightHandSide)); + 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)); + } } } 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 75040d4130..0000000000 --- a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs +++ /dev/null @@ -1,162 +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 _visitedQueryTable = []; - private readonly Dictionary _visitedSerializerTable = []; - - 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; - } - - foreach (ResourceFieldChainExpression chain in sparseFieldSet.FieldChains) - { - builder.Add(chain); - } - } - - return mergedTable.ToDictionary(pair => pair.Key, pair => new SparseFieldSetExpression(pair.Value.ToImmutable())); - } - - /// - public SparseFieldSetExpression? GetSparseFieldSetExpressionForQuery(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 GetSparseFieldSetExpressionForSerializer(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) - { - IImmutableSet intersectedChains = first.FieldChains.Intersect(second.FieldChains); - return intersectedChains.Count == 0 ? first : new SparseFieldSetExpression(intersectedChains); - } - - /// - public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) - { - SparseFieldSetExpression? expression = GetSparseFieldSetExpressionForQuery(resourceType); - return expression == null ? ImmutableHashSet.Empty : expression.Fields; - } - - /// - 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) - { - return GetSparseFieldSetExpressionForSerializer(resourceType).Fields; - } - - 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() - { - _visitedQueryTable.Clear(); - _visitedSerializerTable.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/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs index b1ec5f2c7f..b2eff2ece8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -193,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/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index 74f68dfcb9..8562573063 100644 --- a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -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/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 9fed94918b..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); } } 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/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index d6ae3a995a..b96d2fea76 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -26,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); @@ -41,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; @@ -50,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(); @@ -140,7 +140,7 @@ protected virtual AtomicResultObject ConvertOperation(OperationContainer? operat resourceObject = rootNode.GetResponseData().Single(); - _sparseFieldSetCache.Reset(); + _sparseFieldSetEvaluator.Reset(); _resourceToTreeNodeCache.Clear(); } @@ -225,7 +225,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (!isRelationship) { - SparseFieldSetExpression fieldSetExpression = _sparseFieldSetCache.GetSparseFieldSetExpressionForSerializer(resourceType); + SparseFieldSetExpression fieldSetExpression = _sparseFieldSetEvaluator.GetSparseFieldSetForSerializer(resourceType); resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSetExpression); resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); @@ -245,7 +245,12 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc foreach (AttrAttribute attr in resourceType.Attributes) { - if (!fieldSet.Fields.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; } @@ -265,11 +270,8 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (value != null && attr.IsCompound) { - // Check if any field chains exist for this compound attribute with deeper paths - List subChains = fieldSet.FieldChains.Where(chain => chain.Fields.Count > 1 && chain.Fields[0].Equals(attr)) - .Select(chain => new ResourceFieldChainExpression(chain.Fields.RemoveAt(0))).ToList(); - - var context = new CompoundSelectionContext(subChains.Count > 0 ? subChains : null); + IImmutableDictionary? requestedChildren = + element.Children.Count > 0 ? element.Children : null; if (attr.IsCollection && value is IEnumerable collection) { @@ -277,14 +279,14 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc foreach (object? item in collection) { - prunedList.Add(item == null ? null : ConvertCompoundObject(item, attr.CompoundType!, context)); + prunedList.Add(item == null ? null : ConvertCompoundObject(item, attr.CompoundType!, requestedChildren)); } value = prunedList; } else { - value = ConvertCompoundObject(value, attr.CompoundType!, context); + value = ConvertCompoundObject(value, attr.CompoundType!, requestedChildren); } } @@ -294,7 +296,8 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc return attrMap.Count > 0 ? attrMap : null; } - private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, CompoundSelectionContext context) + private Dictionary ConvertCompoundObject(object compoundInstance, CompoundType compoundType, + IImmutableDictionary? childLookup) { var prunedMap = new Dictionary(); @@ -305,7 +308,9 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc continue; } - if (context.DirectFields != null && !context.DirectFields.Contains(childAttr)) + SparseFieldSetElementExpression? childElement = null; + + if (childLookup != null && !childLookup.TryGetValue(childAttr, out childElement)) { continue; } @@ -325,8 +330,8 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc if (childValue != null && childAttr.IsCompound) { - List? deeperChains = context.DeeperChainsByAttribute?.GetValueOrDefault(childAttr); - var childContext = new CompoundSelectionContext(deeperChains); + IImmutableDictionary? deeperChildren = + childElement is { Children.Count: > 0 } ? childElement.Children : null; if (childAttr.IsCollection && childValue is IEnumerable collection) { @@ -334,14 +339,14 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc foreach (object? item in collection) { - prunedList.Add(item == null ? null : ConvertCompoundObject(item, childAttr.CompoundType!, childContext)); + prunedList.Add(item == null ? null : ConvertCompoundObject(item, childAttr.CompoundType!, deeperChildren)); } childValue = prunedList; } else { - childValue = ConvertCompoundObject(childValue, childAttr.CompoundType!, childContext); + childValue = ConvertCompoundObject(childValue, childAttr.CompoundType!, deeperChildren); } } @@ -405,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); } @@ -488,21 +493,4 @@ private static SingleOrManyData GetRelationshipData(Re return !hasErrors && _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; } - - private readonly struct CompoundSelectionContext - { - public HashSet? DirectFields { get; } - public Dictionary>? DeeperChainsByAttribute { get; } - - public CompoundSelectionContext(IReadOnlyList? fieldChains) - { - if (fieldChains is { Count: > 0 }) - { - DirectFields = fieldChains.Select(c => c.Fields[0]).ToHashSet(); - - DeeperChainsByAttribute = fieldChains.Where(c => c.Fields.Count > 1).GroupBy(c => c.Fields[0]).ToDictionary(g => g.Key, - g => g.Select(c => new ResourceFieldChainExpression(c.Fields.RemoveAt(0))).ToList()); - } - } - } } 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/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs index 826934702a..06cdec8d8e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -80,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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs index 511923ce0b..87a26b8943 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesFakers.cs @@ -35,7 +35,8 @@ public CompoundAttributesFakers() .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.MaidenName, faker => faker.Name.LastName()) + .RuleFor(name => name.SecretAlias, faker => faker.Name.FindName())); _lazyContactCompanyFaker = new Lazy>(() => new Faker() .MakeDeterministic() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs index e5ff5d236e..ba1404947f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/CompoundAttributesSparseFieldSetTests.cs @@ -38,20 +38,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("remarks"); - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); - - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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(); @@ -76,20 +71,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName,remarks"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("remarks"); - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); - - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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(); @@ -111,18 +101,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?fields[contacts]=content"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("remarks"); - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("content"); + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement contentElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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); @@ -223,19 +210,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/addressBooks/{addressBook.StringId}?fields[addressBooks]=favorites.name.lastName"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = 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"); - - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement favoritesElement = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("favorites"); + 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++) @@ -264,16 +247,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.firstName,content.name.lastName"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement content = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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); @@ -303,16 +282,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?fields[contacts]=content.name.lastName,content.company.name"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement content = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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(); @@ -342,19 +317,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/contacts/{contact.StringId}?include=addressBook&fields[contacts]=content.name.lastName,addressBook&fields[addressBooks]=name"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + (HttpResponseMessage httpResponse, JsonElement responseElement) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Attributes.Should().ContainKey("name"); - responseDocument.Included[0].Attributes.Should().NotContainKey("categories"); - string responseRaw = await httpResponse.Content.ReadAsStringAsync(); - using JsonDocument jsonDocument = JsonDocument.Parse(responseRaw); - JsonElement content = jsonDocument.RootElement.GetProperty("data").GetProperty("attributes").GetProperty("content"); + 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(); @@ -396,4 +371,135 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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/Entities.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs index 59073e855f..5c46718243 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompoundAttributes/Entities.cs @@ -125,6 +125,10 @@ public sealed class ContactName // 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)] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs index 0a995249cc..d2f8363572 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs @@ -78,9 +78,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/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs index d149a3a619..a4f504c5cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -75,9 +75,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/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs index ed36428808..11127eb2f3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -76,9 +76,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/LengthSortParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs index d888792b1c..c18fff3bfb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs @@ -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/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs index 39b16b0b6f..97596ea84e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -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/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/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 22d0e537dd..8af11939a0 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/QueryExpressionTests.cs @@ -114,8 +114,8 @@ public sealed class QueryExpressionTests TestExpressionFactory.Instance.SparseFieldSet() ], [ - TestExpressionFactory.Instance.SparseFieldTable(), - TestExpressionFactory.Instance.SparseFieldTable() + TestExpressionFactory.Instance.SparseFieldSetElement(), + TestExpressionFactory.Instance.SparseFieldSetElement() ] }; @@ -311,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; @@ -328,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() @@ -455,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 index abbe63edb1..e04307ca67 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/SparseFieldSetExpressionExtensionsTests.cs @@ -21,7 +21,7 @@ public SparseFieldSetExpressionExtensionsTests() } [Fact] - public void Including_preserves_nested_field_chains() + public void Including_preserves_nested_field_elements() { // Arrange ResourceType contactType = _resourceGraph.GetResourceType(); @@ -31,18 +31,30 @@ public void Including_preserves_nested_field_chains() CompoundType nameType = nameAttr.CompoundType!; AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); - var nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); - var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain)); + 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.FieldChains.Should().HaveCount(2); - updatedSet.FieldChains.Should().Contain(nestedChain); + updatedSet.Elements.Should().HaveCount(2); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); - updatedSet.FieldChains.Should().Contain(new ResourceFieldChainExpression(remarksAttr)); + + updatedSet.Elements.Keys.Should().Contain([ + contentAttr, + remarksAttr + ]); + updatedSet.ToString().Should().Be("content.name.lastName,remarks"); } @@ -58,17 +70,26 @@ public void Excluding_removes_matching_field_and_preserves_nested_chains() AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); - var nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); - var remarksChain = new ResourceFieldChainExpression(remarksAttr); - var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain, remarksChain)); + 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.FieldChains.Should().HaveCount(1); - updatedSet.FieldChains.Should().Contain(nestedChain); + updatedSet.Elements.Should().HaveCount(1); + updatedSet.Elements.Values.Single().Field.Should().Be(contentAttr); updatedSet.ToString().Should().Be("content.name.lastName"); } @@ -84,34 +105,127 @@ public void Excluding_top_level_compound_removes_all_its_nested_chains() AttrAttribute lastNameAttr = nameType.GetAttributeByPropertyName(nameof(PersonName.LastName)); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); - var nestedChain = new ResourceFieldChainExpression(ImmutableArray.Create(contentAttr, nameAttr, lastNameAttr)); - var remarksChain = new ResourceFieldChainExpression(remarksAttr); - var initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(nestedChain, remarksChain)); + 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.FieldChains.Should().HaveCount(1); - updatedSet.FieldChains.Should().Contain(remarksChain); + 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(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))); + + 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.FieldChains.Should().HaveCount(2); + updatedSet.Elements.Should().HaveCount(2); updatedSet.ToString().Should().Be("content.name.lastName,remarks"); } @@ -122,15 +236,17 @@ public void Excluding_accepts_nested_lambda_chain() ResourceType contactType = _resourceGraph.GetResourceType(); AttrAttribute remarksAttr = contactType.GetAttributeByPropertyName(nameof(Contact.Remarks)); - SparseFieldSetExpression? initialSet = new SparseFieldSetExpression(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))) - .Including(c => c.Content!.Name!.LastName, _resourceGraph); + 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.FieldChains.Should().HaveCount(1); + updatedSet.Elements.Should().HaveCount(1); updatedSet.ToString().Should().Be("remarks"); } @@ -140,7 +256,11 @@ 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(ImmutableHashSet.Create(new ResourceFieldChainExpression(remarksAttr))); + + var initialSet = + new SparseFieldSetExpression( + ImmutableDictionary.Empty.Add(remarksAttr, + new SparseFieldSetElementExpression(remarksAttr))); // Act SparseFieldSetExpression? updatedSet = initialSet.Including(c => new @@ -151,10 +271,59 @@ public void Including_accepts_anonymous_type_with_nested_and_top_level_lambdas() // Assert updatedSet.Should().NotBeNull(); - updatedSet.FieldChains.Should().HaveCount(2); + 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() { @@ -187,6 +356,9 @@ private sealed class ContactContent [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 05a4130e03..8278d9c34a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -247,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(); 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 4144ac2cac..d8ccffd39e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -121,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 10038f692e..36717885dd 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -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/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index aaad3480ff..e5f53000ca 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -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 a4831a4cb0..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; @@ -115,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 index 386d33c7eb..667ebf2a99 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryableBuilding/ClauseBuilderTests.cs @@ -284,6 +284,35 @@ public void Builds_select_clause_for_compound_collection_attribute_without_crash 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(); @@ -319,6 +348,9 @@ public sealed class CompoundRoot [Attr] public string? Title { get; set; } + [Attr] + public string? Calculated => Title?.ToUpperInvariant(); + [Attr(IsCompound = true)] public CompoundNested? Nested { get; set; } 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); } }