diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a88a657c3b..a573820074 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,8 +48,6 @@ jobs:
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
- 8.0.*
- 9.0.*
10.0.*
- name: Show installed versions
shell: pwsh
@@ -154,8 +152,6 @@ jobs:
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
- 8.0.*
- 9.0.*
10.0.*
- name: Git checkout
uses: actions/checkout@v7
@@ -212,8 +208,6 @@ jobs:
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
- 8.0.*
- 9.0.*
10.0.*
- name: Git checkout
uses: actions/checkout@v7
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e2035beb87..5ba4f27ea5 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -30,8 +30,6 @@ jobs:
uses: actions/setup-dotnet@v6
with:
dotnet-version: |
- 8.0.*
- 9.0.*
10.0.*
- name: Git checkout
uses: actions/checkout@v7
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 94301c838a..ee56ca2661 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -14,4 +14,16 @@
+
+
+
+
+
diff --git a/JsonApiDotNetCore.slnx.DotSettings b/JsonApiDotNetCore.slnx.DotSettings
index 1016e6cda0..8933b09cbb 100644
--- a/JsonApiDotNetCore.slnx.DotSettings
+++ b/JsonApiDotNetCore.slnx.DotSettings
@@ -3,7 +3,6 @@
2000
3000
False
- 58FE19B6-F84A-990C-B25F-E37C391C3BD3/f:ReadOnlySet.cs
SOLUTION
True
SUGGESTION
@@ -600,6 +599,7 @@
True
True
True
+ True
True
Replace argument null check using throw expression with ArgumentNullException.ThrowIfNull
True
diff --git a/ROADMAP.md b/ROADMAP.md
index 0e01efc277..5c3486d999 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,7 +6,7 @@ This document provides an overview of the direction this project is heading and
We have an interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version.
-- Query strings on JSON-mapped columns [#1439](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1439)
+- Compound attributes and collections (query strings, JSON columns, complex types) [#1439](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1439) (Implemented)
- Optimistic concurrency [#1119](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119)
- Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132)
- Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776)
diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs
index 2ab328c074..472f243a91 100644
--- a/benchmarks/Serialization/SerializationBenchmarkBase.cs
+++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs
@@ -43,11 +43,11 @@ protected SerializationBenchmarkBase()
var metaBuilder = new NoMetaBuilder();
IQueryConstraintProvider[] constraintProviders = [];
var resourceDefinitionAccessor = new NeverResourceDefinitionAccessor();
- var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor);
+ var sparseFieldSetEvaluator = new SparseFieldSetEvaluator(constraintProviders, resourceDefinitionAccessor);
var requestQueryStringAccessor = new FakeRequestQueryStringAccessor();
ResponseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache,
- sparseFieldSetCache, requestQueryStringAccessor);
+ sparseFieldSetEvaluator, requestQueryStringAccessor);
}
protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph);
diff --git a/docs/api/index.md b/docs/api/index.md
index 962b1b4cab..c34af03743 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -84,7 +84,7 @@ Commonly used types are listed in the following sections.
- implements
-
-
-- implements
+- implements
## Error handling
diff --git a/docs/usage/faq.md b/docs/usage/faq.md
index 7a8b2868b0..b4057f2ea1 100644
--- a/docs/usage/faq.md
+++ b/docs/usage/faq.md
@@ -133,7 +133,7 @@ Here are some injectable request-scoped types to be aware of:
- `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates).
- `IEnumerable`: Provides access to the parsed query string parameters.
- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render.
-- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects.
+- `ISparseFieldSetEvaluator`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects.
You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships).
diff --git a/package-versions.props b/package-versions.props
index 2fe57c295c..84c539f233 100644
--- a/package-versions.props
+++ b/package-versions.props
@@ -16,7 +16,6 @@
2.0.*
0.10.*
4.1.*
- 10.0.*
18.11.*
2.9.*
4.0.*
@@ -28,24 +27,6 @@
10.0.*
- 10.0.*-*
-
-
-
-
- N/A
-
-
- 9.0.*
- 9.0.*
-
-
-
-
- 8.0.0
-
-
- 8.0.*
- 8.0.*
+ 10.0.*
diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj
index 47e35d23ce..7ca897c154 100644
--- a/src/Examples/DapperExample/DapperExample.csproj
+++ b/src/Examples/DapperExample/DapperExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
@@ -15,8 +15,7 @@
-
+
-
diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs
index 1c2549cf6a..a90db99254 100644
--- a/src/Examples/DapperExample/Program.cs
+++ b/src/Examples/DapperExample/Program.cs
@@ -30,11 +30,7 @@
}
case DatabaseProvider.MySql:
{
-#if NET8_0
- ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
-#else
ServerVersion serverVersion = await ServerVersion.AutoDetectAsync(connectionString);
-#endif
builder.Services.AddMySql(connectionString, serverVersion, optionsAction: options => SetDbContextDebugOptions(options));
diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj
index c7cee804b1..9eb772dae3 100644
--- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj
+++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj
index 806fdf45a5..d3bf381fd3 100644
--- a/src/Examples/GettingStarted/GettingStarted.csproj
+++ b/src/Examples/GettingStarted/GettingStarted.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj
index c7cee804b1..9eb772dae3 100644
--- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj
+++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj
index 806fdf45a5..d3bf381fd3 100644
--- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj
+++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj
index e50874f733..271865a75a 100644
--- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj
+++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
index 4a7205fee7..a47029cca1 100644
--- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
+++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
@@ -85,6 +85,7 @@ private void LogFiltersInTopScope()
FilterExpression[] filtersInTopScope = _constraintProviders
.SelectMany(provider => provider.GetConstraints())
+ .OfType()
.Where(constraint => constraint.Scope == null)
.Select(constraint => constraint.Expression)
.OfType()
diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj
index 73a16ddf4f..60196fe1a5 100644
--- a/src/Examples/ReportsExample/ReportsExample.csproj
+++ b/src/Examples/ReportsExample/ReportsExample.csproj
@@ -1,6 +1,6 @@
- net10.0;net9.0;net8.0
+ net10.0
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs
new file mode 100644
index 0000000000..cce472103c
--- /dev/null
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/CompoundType.cs
@@ -0,0 +1,102 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.Configuration;
+
+///
+/// Metadata about the shape of a compound JSON:API attribute in the resource graph.
+///
+[PublicAPI]
+public sealed class CompoundType : IAttributeContainer
+{
+ private readonly Dictionary _attributesByPublicName = [];
+ private readonly Dictionary _attributesByPropertyName = [];
+
+ ///
+ public Type ClrType { get; }
+
+ ///
+ public IReadOnlyCollection Attributes { get; }
+
+ public CompoundType(Type clrType, IReadOnlyCollection attributes)
+ {
+ ArgumentNullException.ThrowIfNull(clrType);
+ ArgumentNullException.ThrowIfNull(attributes);
+
+ ClrType = clrType;
+ Attributes = attributes;
+
+ foreach (AttrAttribute attribute in Attributes)
+ {
+ _attributesByPublicName.Add(attribute.PublicName, attribute);
+ _attributesByPropertyName.Add(attribute.Property.Name, attribute);
+ }
+ }
+
+ ///
+ public AttrAttribute GetAttributeByPublicName(string publicName)
+ {
+ AttrAttribute? attribute = FindAttributeByPublicName(publicName);
+ return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on compound type '{ClrType.Name}'.");
+ }
+
+ ///
+ public AttrAttribute? FindAttributeByPublicName(string publicName)
+ {
+ ArgumentNullException.ThrowIfNull(publicName);
+
+ return _attributesByPublicName.GetValueOrDefault(publicName);
+ }
+
+ ///
+ public AttrAttribute GetAttributeByPropertyName(string propertyName)
+ {
+ AttrAttribute? attribute = FindAttributeByPropertyName(propertyName);
+ return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on compound type '{ClrType.Name}'.");
+ }
+
+ ///
+ public AttrAttribute? FindAttributeByPropertyName(string propertyName)
+ {
+ ArgumentNullException.ThrowIfNull(propertyName);
+
+ return _attributesByPropertyName.GetValueOrDefault(propertyName);
+ }
+
+ ///
+ public override string ToString()
+ {
+ return ClrType.Name;
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj is null || GetType() != obj.GetType())
+ {
+ return false;
+ }
+
+ var other = (CompoundType)obj;
+ return ClrType == other.ClrType && Attributes.SequenceEqual(other.Attributes);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ var hashCode = new HashCode();
+ hashCode.Add(ClrType);
+
+ foreach (AttrAttribute attribute in Attributes)
+ {
+ hashCode.Add(attribute);
+ }
+
+ return hashCode.ToHashCode();
+ }
+}
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs
new file mode 100644
index 0000000000..5f4e8f567d
--- /dev/null
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/IAttributeContainer.cs
@@ -0,0 +1,41 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.Configuration;
+
+///
+/// Metadata about the shape of a type that contains JSON:API attributes.
+///
+[PublicAPI]
+public interface IAttributeContainer
+{
+ ///
+ /// The CLR type of the container.
+ ///
+ Type ClrType { get; }
+
+ ///
+ /// The attributes in this container.
+ ///
+ IReadOnlyCollection Attributes { get; }
+
+ ///
+ /// Gets the attribute for the specified publicly exposed name. Throws an when not found.
+ ///
+ AttrAttribute GetAttributeByPublicName(string publicName);
+
+ ///
+ /// Attempts to find an attribute by its publicly exposed name. Returns null when not found.
+ ///
+ AttrAttribute? FindAttributeByPublicName(string publicName);
+
+ ///
+ /// Gets the attribute for the specified property name. Throws an when not found.
+ ///
+ AttrAttribute GetAttributeByPropertyName(string propertyName);
+
+ ///
+ /// Attempts to find an attribute by its property name. Returns null when not found.
+ ///
+ AttrAttribute? FindAttributeByPropertyName(string propertyName);
+}
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
index 53ed2f882e..791b072dc0 100644
--- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
@@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Configuration;
/// Metadata about the shape of a JSON:API resource in the resource graph.
///
[PublicAPI]
-public sealed class ResourceType
+public sealed class ResourceType : IAttributeContainer
{
private static readonly IReadOnlySet EmptyResourceTypeSet = new HashSet().AsReadOnly();
private static readonly IReadOnlySet EmptyAttributeSet = new HashSet().AsReadOnly();
@@ -157,12 +157,14 @@ private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet
public AttrAttribute GetAttributeByPublicName(string publicName)
{
AttrAttribute? attribute = FindAttributeByPublicName(publicName);
return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'.");
}
+ ///
public AttrAttribute? FindAttributeByPublicName(string publicName)
{
ArgumentNullException.ThrowIfNull(publicName);
@@ -170,12 +172,14 @@ public AttrAttribute GetAttributeByPublicName(string publicName)
return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null;
}
+ ///
public AttrAttribute GetAttributeByPropertyName(string propertyName)
{
AttrAttribute? attribute = FindAttributeByPropertyName(propertyName);
return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'.");
}
+ ///
public AttrAttribute? FindAttributeByPropertyName(string propertyName)
{
ArgumentNullException.ThrowIfNull(propertyName);
diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
index fd646bfd78..d52c37751e 100644
--- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
+++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj
@@ -1,6 +1,6 @@
- net10.0;net8.0;netstandard1.0
+ net10.0;netstandard1.0
true
true
JsonApiDotNetCore
diff --git a/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs b/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs
deleted file mode 100644
index 9917af9ec7..0000000000
--- a/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs
+++ /dev/null
@@ -1,171 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-#if NET8_0
-#pragma warning disable
-
-// ReadOnlySet was introduced in .NET 9.
-// This file was copied from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Collections/src/System/Collections/Generic/ReadOnlySet.cs
-// and made internal to enable usage on lower .NET versions.
-
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-
-namespace System.Collections.ObjectModel;
-
-/// Represents a read-only, generic set of values.
-/// The type of values in the set.
-[DebuggerDisplay("Count = {Count}")]
-[ExcludeFromCodeCoverage]
-internal class ReadOnlySet : IReadOnlySet, ISet, ICollection
-{
- /// The wrapped set.
- private readonly ISet _set;
-
- /// Initializes a new instance of the class that is a wrapper around the specified set.
- /// The set to wrap.
- public ReadOnlySet(ISet set)
- {
- ArgumentNullException.ThrowIfNull(set);
- _set = set;
- }
-
- /// Gets an empty .
- public static ReadOnlySet Empty { get; } = new ReadOnlySet(new HashSet());
-
- /// Gets the set that is wrapped by this object.
- protected ISet Set => _set;
-
- ///
- public int Count => _set.Count;
-
- ///
- public IEnumerator GetEnumerator() =>
- _set.Count == 0 ? ((IEnumerable)Array.Empty()).GetEnumerator() :
- _set.GetEnumerator();
-
- ///
- IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
-
- ///
- public bool Contains(T item) => _set.Contains(item);
-
- ///
- public bool IsProperSubsetOf(IEnumerable other) => _set.IsProperSubsetOf(other);
-
- ///
- public bool IsProperSupersetOf(IEnumerable other) => _set.IsProperSupersetOf(other);
-
- ///
- public bool IsSubsetOf(IEnumerable other) => _set.IsSubsetOf(other);
-
- ///
- public bool IsSupersetOf(IEnumerable other) => _set.IsSupersetOf(other);
-
- ///
- public bool Overlaps(IEnumerable other) => _set.Overlaps(other);
-
- ///
- public bool SetEquals(IEnumerable other) => _set.SetEquals(other);
-
- ///
- void ICollection.CopyTo(T[] array, int arrayIndex) => _set.CopyTo(array, arrayIndex);
-
- ///
- void ICollection.CopyTo(Array array, int index) => CollectionHelpers.CopyTo(_set, array, index);
-
- ///
- bool ICollection.IsReadOnly => true;
-
- ///
- bool ICollection.IsSynchronized => false;
-
- ///
- object ICollection.SyncRoot => _set is ICollection c ? c.SyncRoot : this;
-
- ///
- bool ISet.Add(T item) => throw new NotSupportedException();
-
- ///
- void ISet.ExceptWith(IEnumerable other) => throw new NotSupportedException();
-
- ///
- void ISet.IntersectWith(IEnumerable other) => throw new NotSupportedException();
-
- ///
- void ISet.SymmetricExceptWith(IEnumerable other) => throw new NotSupportedException();
-
- ///
- void ISet.UnionWith(IEnumerable other) => throw new NotSupportedException();
-
- ///
- void ICollection.Add(T item) => throw new NotSupportedException();
-
- ///
- void ICollection.Clear() => throw new NotSupportedException();
-
- ///
- bool ICollection.Remove(T item) => throw new NotSupportedException();
-
- private static class CollectionHelpers
- {
- private static void ValidateCopyToArguments(int sourceCount, Array array, int index)
- {
- ArgumentNullException.ThrowIfNull(array);
-
- if (array.Rank != 1)
- {
- throw new ArgumentException("Only single dimensional arrays are supported for the requested action.", nameof(array));
- }
-
- if (array.GetLowerBound(0) != 0)
- {
- throw new ArgumentException("The lower bound of target array must be zero.", nameof(array));
- }
-
- ArgumentOutOfRangeException.ThrowIfNegative(index);
- ArgumentOutOfRangeException.ThrowIfGreaterThan(index, array.Length);
-
- if (array.Length - index < sourceCount)
- {
- throw new ArgumentException("Destination array is not long enough to copy all the items in the collection. Check array index and length.");
- }
- }
-
- internal static void CopyTo(ICollection collection, Array array, int index)
- {
- ValidateCopyToArguments(collection.Count, array, index);
-
- if (collection is ICollection nonGenericCollection)
- {
- // Easy out if the ICollection implements the non-generic ICollection
- nonGenericCollection.CopyTo(array, index);
- }
- else if (array is T[] items)
- {
- collection.CopyTo(items, index);
- }
- else
- {
- // We can't cast array of value type to object[], so we don't support widening of primitive types here.
- if (array is not object?[] objects)
- {
- throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array));
- }
-
- try
- {
- foreach (T item in collection)
- {
- objects[index++] = item;
- }
- }
- catch (ArrayTypeMismatchException)
- {
- throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array));
- }
- }
- }
- }
-}
-#endif
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
index 26a660775a..30e8abe857 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs
@@ -1,4 +1,7 @@
using JetBrains.Annotations;
+using JsonApiDotNetCore.Configuration;
+
+// ReSharper disable NonReadonlyMemberInGetHashCode
namespace JsonApiDotNetCore.Resources.Annotations;
@@ -31,6 +34,21 @@ public AttrCapabilities Capabilities
set => _capabilities = value;
}
+ ///
+ /// Indicates whether the underlying property is a collection.
+ ///
+ public bool IsCollection { get; internal set; }
+
+ ///
+ /// Indicates whether this attribute represents a compound object. Defaults to false.
+ ///
+ public bool IsCompound { get; set; }
+
+ ///
+ /// Provides access to nested attributes when is true; otherwise null.
+ ///
+ public CompoundType? CompoundType { get; internal set; }
+
///
public override bool Equals(object? obj)
{
@@ -46,12 +64,13 @@ public override bool Equals(object? obj)
var other = (AttrAttribute)obj;
- return Capabilities == other.Capabilities && base.Equals(other);
+ return Capabilities == other.Capabilities && IsCollection == other.IsCollection && IsCompound == other.IsCompound &&
+ CompoundType == other.CompoundType && base.Equals(other);
}
///
public override int GetHashCode()
{
- return HashCode.Combine(Capabilities, base.GetHashCode());
+ return HashCode.Combine(Capabilities, IsCollection, IsCompound, CompoundType, base.GetHashCode());
}
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs
index a7915240dc..0948dada0e 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs
@@ -11,4 +11,7 @@ public sealed class AttrAttribute : ResourceFieldAttribute
{
///
public AttrCapabilities Capabilities { get; set; }
+
+ ///
+ public bool IsCompound { get; set; }
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
index b580f0576f..c8679585da 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
@@ -41,9 +41,9 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute
public PropertyInfo? InverseNavigationProperty { get; set; }
///
- /// The containing resource type in which this relationship is declared. Identical to .
+ /// The containing resource type in which this relationship is defined. Identical to .
///
- public ResourceType LeftType => Type;
+ public ResourceType LeftType => (ResourceType)ContainingType;
///
/// The resource type this relationship points to. In the case of a relationship, this value will be the collection
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
index 6f54635827..ce970a73b3 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
@@ -16,7 +16,7 @@ public abstract class ResourceFieldAttribute : Attribute
// These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable.
private string? _publicName;
private PropertyInfo? _property;
- private ResourceType? _type;
+ private IAttributeContainer? _containingType;
///
/// The publicly exposed name of this JSON:API field. When not explicitly set, the configured naming convention is applied on the property name.
@@ -49,15 +49,15 @@ internal set
}
///
- /// The containing resource type in which this field is declared.
+ /// The container (resource type or compound type) in which this field is contained.
///
- public ResourceType Type
+ public IAttributeContainer ContainingType
{
- get => _type!;
+ get => _containingType!;
internal set
{
ArgumentNullException.ThrowIfNull(value);
- _type = value;
+ _containingType = value;
}
}
@@ -115,7 +115,8 @@ public virtual void SetValue(object resource, object? newValue)
protected void AssertIsIdentifiable(object? resource)
{
- if (resource is not null and not IIdentifiable)
+ // Fields in a CompoundType represent unidentifiable embedded value objects, so skip IIdentifiable check for them.
+ if (_containingType is not CompoundType && resource is not null and not IIdentifiable)
{
#pragma warning disable CA1062 // Validate arguments of public methods
throw new InvalidOperationException($"Resource of type '{resource.GetType()}' does not implement {nameof(IIdentifiable)}.");
@@ -131,7 +132,14 @@ protected void AssertIsIdentifiable(object? resource)
public string ToFullString()
{
- return $"{_type?.PublicName}:{ToString()}";
+ string prefix = _containingType switch
+ {
+ ResourceType resourceType => resourceType.PublicName,
+ CompoundType compoundType => compoundType.ClrType.Name,
+ _ => string.Empty
+ };
+
+ return $"{prefix}:{ToString()}";
}
///
diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
index 6ae7d5f506..8e6097b96f 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
@@ -18,12 +18,12 @@ public class OperationsProcessor : IOperationsProcessor
private readonly IResourceGraph _resourceGraph;
private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
- private readonly ISparseFieldSetCache _sparseFieldSetCache;
+ private readonly ISparseFieldSetEvaluator _sparseFieldSetEvaluator;
private readonly LocalIdValidator _localIdValidator;
public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory,
ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields,
- ISparseFieldSetCache sparseFieldSetCache)
+ ISparseFieldSetEvaluator sparseFieldSetEvaluator)
{
ArgumentNullException.ThrowIfNull(operationProcessorAccessor);
ArgumentNullException.ThrowIfNull(operationsTransactionFactory);
@@ -31,7 +31,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
ArgumentNullException.ThrowIfNull(resourceGraph);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(targetedFields);
- ArgumentNullException.ThrowIfNull(sparseFieldSetCache);
+ ArgumentNullException.ThrowIfNull(sparseFieldSetEvaluator);
_operationProcessorAccessor = operationProcessorAccessor;
_operationsTransactionFactory = operationsTransactionFactory;
@@ -39,7 +39,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
_resourceGraph = resourceGraph;
_request = request;
_targetedFields = targetedFields;
- _sparseFieldSetCache = sparseFieldSetCache;
+ _sparseFieldSetEvaluator = sparseFieldSetEvaluator;
_localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph);
}
@@ -73,7 +73,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso
await transaction.AfterProcessOperationAsync(cancellationToken);
- _sparseFieldSetCache.Reset();
+ _sparseFieldSetEvaluator.Reset();
}
indexTracker.Reset();
diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs
index cbb37d5013..6168604223 100644
--- a/src/JsonApiDotNetCore/CollectionExtensions.cs
+++ b/src/JsonApiDotNetCore/CollectionExtensions.cs
@@ -1,3 +1,4 @@
+using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
@@ -59,7 +60,7 @@ public static IEnumerable ToEnumerable(this LinkedListNode? startNode)
}
}
- public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second,
+ public static bool DictionaryEquals(this IImmutableDictionary? first, IImmutableDictionary? second,
IEqualityComparer? valueComparer = null)
{
if (ReferenceEquals(first, second))
diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
index a2c4f3283c..679d1b8f18 100644
--- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
+++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
@@ -1,5 +1,6 @@
using System.Linq.Expressions;
using JetBrains.Annotations;
+using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
@@ -43,6 +44,37 @@ ResourceType GetResourceType()
///
ResourceType? FindResourceType(Type resourceClrType);
+ ///
+ /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found.
+ ///
+ ResourceType? FindResourceType()
+ where TResource : class, IIdentifiable;
+
+ ///
+ /// Gets the metadata for all registered compound types.
+ ///
+ IReadOnlySet GetCompoundTypes();
+
+ ///
+ /// Gets the metadata for the compound type of the specified CLR type. Throws an when not found.
+ ///
+ CompoundType GetCompoundType(Type clrType);
+
+ ///
+ /// Gets the metadata for the compound type of the specified CLR type. Throws an when not found.
+ ///
+ CompoundType GetCompoundType();
+
+ ///
+ /// Attempts to get metadata for the compound type of the specified CLR type. Returns null when not found.
+ ///
+ CompoundType? FindCompoundType(Type clrType);
+
+ ///
+ /// Attempts to get metadata for the compound type of the specified CLR type. Returns null when not found.
+ ///
+ CompoundType? FindCompoundType();
+
///
/// Gets the fields (attributes and relationships) for that are targeted by the selector.
///
@@ -84,4 +116,35 @@ IReadOnlyCollection GetAttributes(Expression
IReadOnlyCollection GetRelationships(Expression> selector)
where TResource : class, IIdentifiable;
+
+ ///
+ /// Resolves the specified property chain selector into a .
+ ///
+ ///
+ /// The resource CLR type for which to resolve the field chain.
+ ///
+ ///
+ /// Should be of the form: resource.Field1.ChildField2
+ /// ]]>
+ ///
+ ResourceFieldChainExpression GetFieldChain(Expression> selector)
+ where TResource : class, IIdentifiable;
+
+ ///
+ /// Resolves the specified property chain selector into a collection of s.
+ ///
+ ///
+ /// The resource CLR type for which to resolve field chains.
+ ///
+ ///
+ /// Should be of the form: resource.Field1.ChildField2
+ /// ]]>
+ /// or: new { resource.Field1, resource.Field2.ChildField3 }
+ /// ]]>
+ ///
+ IReadOnlyCollection GetFieldChains(Expression> selector)
+ where TResource : class, IIdentifiable;
}
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
index c20a91fe2e..2c3a4f83d1 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
@@ -173,7 +173,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes)
_services.TryAddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>));
_services.TryAddScoped();
_services.TryAddScoped();
- _services.TryAddScoped();
+ _services.TryAddScoped();
_services.TryAddScoped();
_services.TryAddScoped();
_services.TryAddSingleton();
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
index 8d7a4a29e5..5ca1438090 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs
@@ -31,20 +31,44 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt
IServiceProvider serviceProvider = GetScopedServiceProvider();
var request = serviceProvider.GetRequiredService();
- bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request);
- if (!isTopResourceInPrimaryRequest)
+ if (!IsAtPrimaryEndpoint(request))
{
return false;
}
- if (request.WriteOperation == WriteOperationKind.UpdateResource)
+ if (string.IsNullOrEmpty(parentEntry.Key))
{
- var targetedFields = serviceProvider.GetRequiredService();
- return IsFieldTargeted(entry, targetedFields);
+ if (request.WriteOperation == WriteOperationKind.UpdateResource)
+ {
+ var targetedFields = serviceProvider.GetRequiredService();
+ return IsFieldTargeted(entry, targetedFields);
+ }
+
+ return true;
+ }
+
+ var resourceGraph = serviceProvider.GetRequiredService();
+ return IsInCompoundType(parentEntry, resourceGraph);
+ }
+
+ private static bool IsInCompoundType(ValidationEntry parentEntry, IResourceGraph resourceGraph)
+ {
+ Type modelType = parentEntry.Metadata.ModelType;
+
+ if (resourceGraph.FindCompoundType(modelType) != null)
+ {
+ return true;
+ }
+
+ Type? elementType = CollectionConverter.Instance.FindCollectionElementType(modelType);
+
+ if (elementType != null && resourceGraph.FindCompoundType(elementType) != null)
+ {
+ return true;
}
- return true;
+ return false;
}
private IServiceProvider GetScopedServiceProvider()
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
index a4ffd082d3..f61bc028e4 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
@@ -1,7 +1,9 @@
+using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq.Expressions;
using System.Reflection;
using JetBrains.Annotations;
+using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
@@ -13,21 +15,32 @@ public sealed class ResourceGraph : IResourceGraph
{
private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core");
+ private static readonly IReadOnlySet EmptyCompoundTypeSet = new HashSet().AsReadOnly();
+
private readonly IReadOnlySet _resourceTypeSet;
private readonly Dictionary _resourceTypesByClrType = [];
private readonly Dictionary _resourceTypesByPublicName = [];
+ private readonly IReadOnlySet _compoundTypeSet;
+ private readonly Dictionary _compoundTypesByClrType = [];
- public ResourceGraph(IReadOnlySet resourceTypeSet)
+ public ResourceGraph(IReadOnlySet resourceTypeSet, IReadOnlySet compoundTypeSet)
{
ArgumentNullException.ThrowIfNull(resourceTypeSet);
+ ArgumentNullException.ThrowIfNull(compoundTypeSet);
_resourceTypeSet = resourceTypeSet;
+ _compoundTypeSet = compoundTypeSet;
foreach (ResourceType resourceType in resourceTypeSet)
{
_resourceTypesByClrType.Add(resourceType.ClrType, resourceType);
_resourceTypesByPublicName.Add(resourceType.PublicName, resourceType);
}
+
+ foreach (CompoundType compoundType in compoundTypeSet)
+ {
+ _compoundTypesByClrType.Add(compoundType.ClrType, compoundType);
+ }
}
///
@@ -79,6 +92,45 @@ public ResourceType GetResourceType(Type resourceClrType)
return _resourceTypesByClrType.GetValueOrDefault(typeToFind);
}
+ ///
+ public IReadOnlySet GetCompoundTypes()
+ {
+ return _compoundTypeSet;
+ }
+
+ ///
+ public CompoundType GetCompoundType(Type clrType)
+ {
+ CompoundType? compoundType = FindCompoundType(clrType);
+
+ if (compoundType == null)
+ {
+ throw new InvalidOperationException($"Compound type '{clrType}' does not exist in the resource graph.");
+ }
+
+ return compoundType;
+ }
+
+ ///
+ public CompoundType GetCompoundType()
+ {
+ return GetCompoundType(typeof(TCompound));
+ }
+
+ ///
+ public CompoundType? FindCompoundType(Type clrType)
+ {
+ ArgumentNullException.ThrowIfNull(clrType);
+
+ return _compoundTypesByClrType.GetValueOrDefault(clrType);
+ }
+
+ ///
+ public CompoundType? FindCompoundType()
+ {
+ return FindCompoundType(typeof(TCompound));
+ }
+
private bool IsLazyLoadingProxyForResourceType(Type resourceClrType)
{
return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false;
@@ -91,6 +143,13 @@ public ResourceType GetResourceType()
return GetResourceType(typeof(TResource));
}
+ ///
+ public ResourceType? FindResourceType()
+ where TResource : class, IIdentifiable
+ {
+ return FindResourceType(typeof(TResource));
+ }
+
///
public IReadOnlyCollection GetFields(Expression> selector)
where TResource : class, IIdentifiable
@@ -118,6 +177,161 @@ public IReadOnlyCollection GetRelationships(Ex
return FilterFields(selector);
}
+ ///
+ public ResourceFieldChainExpression GetFieldChain(Expression> selector)
+ where TResource : class, IIdentifiable
+ {
+ ArgumentNullException.ThrowIfNull(selector);
+
+ IReadOnlyCollection chains = GetFieldChains(selector);
+
+ if (chains.Count != 1)
+ {
+ throw new ArgumentException($"The expression '{selector}' must select a single property or chain.", nameof(selector));
+ }
+
+ return chains.First();
+ }
+
+ ///
+ public IReadOnlyCollection GetFieldChains(Expression> selector)
+ where TResource : class, IIdentifiable
+ {
+ ArgumentNullException.ThrowIfNull(selector);
+
+ ResourceType resourceType = GetResourceType();
+ return ResolveFieldChains(selector, resourceType);
+ }
+
+ private ReadOnlyCollection ResolveFieldChains(Expression> selector,
+ ResourceType resourceType)
+ {
+ Expression selectorBody = RemoveConvert(selector.Body);
+ ParameterExpression parameter = selector.Parameters[0];
+
+ List> memberChains = [];
+
+ if (selectorBody is MemberExpression memberExpression)
+ {
+ memberChains.Add(ExtractMemberChain(memberExpression, parameter, selector));
+ }
+ else if (selectorBody is NewExpression newExpression)
+ {
+ foreach (Expression argument in newExpression.Arguments)
+ {
+ Expression argumentBody = RemoveConvert(argument);
+
+ if (argumentBody is not MemberExpression argMemberExpr)
+ {
+ throw new ArgumentException($"The expression '{selector}' contains an invalid member selection. Expected member access expression.",
+ nameof(selector));
+ }
+
+ memberChains.Add(ExtractMemberChain(argMemberExpr, parameter, selector));
+ }
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"The expression '{selector}' should select a single property or chain, or select multiple properties into an anonymous type. " +
+ "For example: 'article => article.Title' or 'article => article.Details.Nested.Description' or 'article => new { article.Title, article.Details.Nested.Description }'.",
+ nameof(selector));
+ }
+
+ List fieldChains = [];
+
+ foreach (List memberChain in memberChains)
+ {
+ fieldChains.Add(ResolveSingleFieldChain(memberChain, resourceType, selector));
+ }
+
+ return fieldChains.AsReadOnly();
+ }
+
+ private static List ExtractMemberChain(MemberExpression memberExpression, ParameterExpression parameter, Expression selector)
+ {
+ List members = [];
+ Expression? current = memberExpression;
+
+ while (current != null)
+ {
+ current = RemoveConvert(current);
+
+ if (current == parameter)
+ {
+ members.Reverse();
+ return members;
+ }
+
+ if (current is MemberExpression memberExpr)
+ {
+ members.Add(memberExpr.Member);
+ current = memberExpr.Expression;
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"The expression '{selector}' contains an invalid expression '{current}'. Expected property member access on '{parameter.Name}'.",
+ nameof(selector));
+ }
+ }
+
+ throw new ArgumentException($"The expression '{selector}' does not originate from parameter '{parameter.Name}'.", nameof(selector));
+ }
+
+ private static ResourceFieldChainExpression ResolveSingleFieldChain(List members, ResourceType resourceType, Expression selector)
+ {
+ List fields = [];
+ IAttributeContainer currentContainer = resourceType;
+
+ for (int index = 0; index < members.Count; index++)
+ {
+ MemberInfo member = members[index];
+ ResourceFieldAttribute? field = null;
+
+ if (currentContainer is ResourceType currentResourceType)
+ {
+ field = currentResourceType.FindAttributeByPropertyName(member.Name) ??
+ (ResourceFieldAttribute?)currentResourceType.FindRelationshipByPropertyName(member.Name);
+ }
+ else if (currentContainer is CompoundType compoundType)
+ {
+ field = compoundType.FindAttributeByPropertyName(member.Name);
+ }
+
+ if (field == null)
+ {
+ string containerName = currentContainer is ResourceType resType ? resType.PublicName : currentContainer.ClrType.Name;
+
+ throw new ArgumentException(
+ $"The expression '{selector}' contains member '{member.Name}', which is not exposed as a JSON:API field on '{containerName}'.",
+ nameof(selector));
+ }
+
+ fields.Add(field);
+
+ if (index < members.Count - 1)
+ {
+ if (field is AttrAttribute { IsCompound: true, CompoundType: not null } compoundAttr)
+ {
+ currentContainer = compoundAttr.CompoundType;
+ }
+ else if (field is RelationshipAttribute relationshipAttr)
+ {
+ currentContainer = relationshipAttr.RightType;
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"The expression '{selector}' contains member '{field.Property.Name}', which cannot have nested fields because it is neither a compound attribute nor a relationship.",
+ nameof(selector));
+ }
+ }
+ }
+
+ return new ResourceFieldChainExpression(fields.ToImmutableArray());
+ }
+
private ReadOnlyCollection FilterFields(Expression> selector)
where TResource : class, IIdentifiable
where TField : ResourceFieldAttribute
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
index 5f020cb4d9..caa384a548 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
@@ -14,11 +14,12 @@ namespace JsonApiDotNetCore.Configuration;
/// Builds and configures the .
///
[PublicAPI]
-public partial class ResourceGraphBuilder
+public sealed partial class ResourceGraphBuilder
{
private readonly IJsonApiOptions _options;
private readonly ILogger _logger;
private readonly Dictionary _resourceTypesByClrType = [];
+ private readonly Dictionary _compoundTypesByClrType = [];
private readonly HashSet _nonIdentifiableTypes = [];
private readonly TypeLocator _typeLocator = new();
@@ -36,21 +37,25 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor
///
public IResourceGraph Build()
{
+ DiscoverCompoundTypes();
+ AssertNoCyclesInCompoundTypes();
+
foreach (Type nonIdentifiableType in _nonIdentifiableTypes)
{
LogResourceTypeDoesNotImplementIdentifiable(nonIdentifiableType, nameof(IIdentifiable), nameof(SuppressWarningNotIdentifiable));
}
IReadOnlySet resourceTypes = _resourceTypesByClrType.Values.ToHashSet().AsReadOnly();
+ IReadOnlySet compoundTypes = _compoundTypesByClrType.Values.ToHashSet().AsReadOnly();
if (resourceTypes.Count == 0)
{
LogResourceGraphIsEmpty();
}
- var resourceGraph = new ResourceGraph(resourceTypes);
+ var resourceGraph = new ResourceGraph(resourceTypes, compoundTypes);
- SetFieldTypes(resourceGraph);
+ SetFieldContainingTypes(resourceGraph);
SetRelationshipTypes(resourceGraph);
SetDirectlyDerivedTypes(resourceGraph);
ValidateFieldsInDerivedTypes(resourceGraph);
@@ -58,11 +63,22 @@ public IResourceGraph Build()
return resourceGraph;
}
- private static void SetFieldTypes(ResourceGraph resourceGraph)
+ private static void SetFieldContainingTypes(ResourceGraph resourceGraph)
{
- foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields))
+ foreach (ResourceType resourceType in resourceGraph.GetResourceTypes())
+ {
+ foreach (ResourceFieldAttribute field in resourceType.Fields)
+ {
+ field.ContainingType = resourceType;
+ }
+ }
+
+ foreach (CompoundType compoundType in resourceGraph.GetCompoundTypes())
{
- field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!);
+ foreach (AttrAttribute attribute in compoundType.Attributes)
+ {
+ attribute.ContainingType = compoundType;
+ }
}
}
@@ -70,9 +86,15 @@ private static void SetRelationshipTypes(ResourceGraph resourceGraph)
{
foreach (RelationshipAttribute relationship in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships))
{
- Type rightClrType = relationship is HasOneAttribute
+ Type? rightClrType = relationship is HasOneAttribute
? relationship.Property.PropertyType
- : relationship.Property.PropertyType.GetGenericArguments()[0];
+ : CollectionConverter.Instance.FindCollectionElementType(relationship.Property.PropertyType);
+
+ if (rightClrType == null)
+ {
+ throw new InvalidConfigurationException(
+ $"To-many relationship '{relationship.LeftType.ClrType}.{relationship.Property.Name}' must use a collection type.");
+ }
ResourceType? rightType = resourceGraph.FindResourceType(rightClrType);
@@ -331,11 +353,19 @@ private Dictionary.ValueCollection GetAttributes(Type res
SetPublicName(attribute, property);
attribute.Property = property;
+ attribute.IsCollection = property.PropertyType != typeof(string) &&
+ CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null;
+
if (!attribute.HasExplicitCapabilities)
{
attribute.Capabilities = _options.DefaultAttrCapabilities;
}
+ if (attribute.IsCompound)
+ {
+ attribute.Capabilities &= ~AttrCapabilities.AllowSort;
+ }
+
IncludeField(attributesByName, attribute);
}
@@ -347,6 +377,160 @@ private Dictionary.ValueCollection GetAttributes(Type res
return attributesByName.Values;
}
+ private void DiscoverCompoundTypes()
+ {
+ var pendingAttributes = new Queue();
+
+ foreach (ResourceType resourceType in _resourceTypesByClrType.Values)
+ {
+ foreach (AttrAttribute attribute in resourceType.Attributes)
+ {
+ if (attribute.IsCompound)
+ {
+ pendingAttributes.Enqueue(attribute);
+ }
+ }
+ }
+
+ while (pendingAttributes.Count > 0)
+ {
+ AttrAttribute compoundAttribute = pendingAttributes.Dequeue();
+ CompoundType compoundType = GetOrCreateCompoundType(compoundAttribute, pendingAttributes);
+ compoundAttribute.CompoundType = compoundType;
+ }
+ }
+
+ private CompoundType GetOrCreateCompoundType(AttrAttribute compoundAttribute, Queue pendingAttributes)
+ {
+ Type compoundClrType = GetCompoundClrType(compoundAttribute.Property, compoundAttribute.IsCollection);
+
+ if (compoundClrType.IsOrImplementsInterface())
+ {
+ throw new InvalidConfigurationException(
+ $"Property '{compoundAttribute.Property.DeclaringType!.Name}.{compoundAttribute.Property.Name}' implements IIdentifiable and must be exposed as a JSON:API relationship using [HasOne] or [HasMany], not as a compound attribute.");
+ }
+
+ compoundAttribute.Capabilities &= ~AttrCapabilities.AllowSort;
+
+ if (_resourceTypesByClrType.ContainsKey(compoundClrType))
+ {
+ throw new InvalidConfigurationException($"Type '{compoundClrType}' cannot be both a resource type and a compound type.");
+ }
+
+ _nonIdentifiableTypes.Remove(compoundClrType);
+
+ if (_compoundTypesByClrType.TryGetValue(compoundClrType, out CompoundType? existingCompoundType))
+ {
+ return existingCompoundType;
+ }
+
+ if (compoundClrType.GetCustomAttribute(true) != null)
+ {
+ throw new InvalidConfigurationException($"Type '{compoundClrType}' is a compound type and cannot be annotated with [Resource].");
+ }
+
+ var attributesByName = new Dictionary();
+
+ foreach (PropertyInfo property in compoundClrType.GetProperties())
+ {
+ var relationship = property.GetCustomAttribute(true);
+
+ if (relationship != null)
+ {
+ throw new InvalidConfigurationException(
+ $"Property '{compoundClrType.Name}.{property.Name}' in compound type '{compoundClrType.Name}' cannot be annotated with [{relationship.GetType().Name}]. Compound types cannot contain relationships.");
+ }
+
+ var eagerLoad = property.GetCustomAttribute(true);
+
+ if (eagerLoad != null)
+ {
+ throw new InvalidConfigurationException(
+ $"Property '{compoundClrType.Name}.{property.Name}' in compound type '{compoundClrType.Name}' cannot be annotated with [EagerLoad].");
+ }
+
+ var attribute = property.GetCustomAttribute(true);
+
+ if (attribute != null)
+ {
+ SetPublicName(attribute, property);
+ attribute.Property = property;
+
+ attribute.IsCollection = property.PropertyType != typeof(string) &&
+ CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) != null;
+
+ if (!attribute.HasExplicitCapabilities)
+ {
+ attribute.Capabilities = _options.DefaultAttrCapabilities;
+ }
+
+ if (attribute.IsCompound)
+ {
+ attribute.Capabilities &= ~AttrCapabilities.AllowSort;
+ pendingAttributes.Enqueue(attribute);
+ }
+
+ IncludeField(attributesByName, attribute);
+ }
+ }
+
+ var newCompoundType = new CompoundType(compoundClrType, attributesByName.Values);
+
+ foreach (AttrAttribute attribute in newCompoundType.Attributes)
+ {
+ attribute.ContainingType = newCompoundType;
+ }
+
+ _compoundTypesByClrType.Add(compoundClrType, newCompoundType);
+ return newCompoundType;
+ }
+
+ private void AssertNoCyclesInCompoundTypes()
+ {
+ HashSet visited = [];
+ HashSet inPath = [];
+
+ foreach (Type compoundType in _compoundTypesByClrType.Keys)
+ {
+ AssertNoCyclesInCompoundTypes(compoundType, visited, inPath);
+ }
+ }
+
+ private void AssertNoCyclesInCompoundTypes(Type currentType, HashSet visited, HashSet inPath)
+ {
+ if (inPath.Contains(currentType))
+ {
+ throw new InvalidConfigurationException($"Circular reference detected: compound type '{currentType}' references itself directly or indirectly.");
+ }
+
+ if (!visited.Add(currentType))
+ {
+ return;
+ }
+
+ inPath.Add(currentType);
+
+ CompoundType compoundType = _compoundTypesByClrType[currentType];
+
+ foreach (AttrAttribute attribute in compoundType.Attributes)
+ {
+ if (attribute is { IsCompound: true, CompoundType: not null })
+ {
+ AssertNoCyclesInCompoundTypes(attribute.CompoundType.ClrType, visited, inPath);
+ }
+ }
+
+ inPath.Remove(currentType);
+ }
+
+ private static Type GetCompoundClrType(PropertyInfo property, bool isCollection)
+ {
+ Type propertyType = property.PropertyType;
+ Type? elementType = isCollection ? CollectionConverter.Instance.FindCollectionElementType(propertyType) : null;
+ Type targetType = elementType ?? Nullable.GetUnderlyingType(propertyType) ?? propertyType;
+ return targetType;
+ }
+
private Dictionary.ValueCollection GetRelationships(Type resourceClrType)
{
var relationshipsByName = new Dictionary();
diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
index 48655941d4..ef95f58bd7 100644
--- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
+++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
@@ -32,7 +32,7 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac
{
ArgumentNullException.ThrowIfNull(services);
- SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty());
+ SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? []);
return services;
}
diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs
index 0f82f6ffea..eea472907f 100644
--- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs
+++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Reflection;
-using System.Text.Json.Serialization;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Resources;
@@ -94,17 +93,33 @@ private static List FromModelStateDictionary(IReadOnlyDictionary(true);
- return jsonNameAttribute?.Name ?? property.Name;
- }
}
}
diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
index c1a6e96ff2..994654b61a 100644
--- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
+++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
@@ -1,6 +1,6 @@
- net10.0;net8.0
+ net10.0
true
true
@@ -39,12 +39,6 @@
-
-
-
diff --git a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs
index 72a311d246..ce995fd1b3 100644
--- a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs
+++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs
@@ -38,6 +38,7 @@ public void Set(IncludeExpression include)
_include = _constraintProviders
.SelectMany(provider => provider.GetConstraints())
+ .OfType()
.Where(constraint => constraint.Scope == null)
.Select(constraint => constraint.Expression)
.OfType()
diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs
index 4cab3e203b..1e718b8599 100644
--- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs
+++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs
@@ -1,28 +1,21 @@
-using JetBrains.Annotations;
-using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
namespace JsonApiDotNetCore.Queries;
///
-/// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression.
+/// The expression that represents a parsed query string parameter, along with the scope to which it applies.
///
-[PublicAPI]
-public class ExpressionInScope
+public abstract class ExpressionInScope
{
- public ResourceFieldChainExpression? Scope { get; }
public QueryExpression Expression { get; }
- public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression)
+ ///
+ /// This constructor prevents external inheritance.
+ ///
+ private protected ExpressionInScope(QueryExpression expression)
{
ArgumentNullException.ThrowIfNull(expression);
- Scope = scope;
Expression = expression;
}
-
- public override string ToString()
- {
- return $"{Scope} => {Expression}";
- }
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs
index bc6982af5a..f06470ccea 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs
@@ -32,6 +32,13 @@ private IncludeExpression()
Elements = ImmutableHashSet.Empty;
}
+ internal static IncludeExpression Create(IImmutableSet elements)
+ {
+ ArgumentNullException.ThrowIfNull(elements);
+
+ return elements.Count > 0 ? new IncludeExpression(elements) : Empty;
+ }
+
public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
{
return visitor.VisitInclude(this, argument);
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs
index dac7493109..3a4031c176 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs
@@ -8,7 +8,28 @@ namespace JsonApiDotNetCore.Queries.Expressions;
///
public abstract class QueryExpression
{
+ ///
+ /// Dispatches the current query expression to the specified visitor with the given argument.
+ ///
+ ///
+ /// The type of the argument passed to the visitor.
+ ///
+ ///
+ /// The type of the result returned by the visitor.
+ ///
+ ///
+ /// A visitor that processes the query expression.
+ ///
+ ///
+ /// An argument to pass to the visitor.
+ ///
+ ///
+ /// The result produced by the visitor.
+ ///
public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument);
+ ///
+ /// Returns a string representation that includes all details of the expression.
+ ///
public abstract string ToFullString();
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs
index 2da39bb2fc..b3f314d288 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs
@@ -1,6 +1,6 @@
using System.Collections.Immutable;
using JetBrains.Annotations;
-using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Resources.Annotations;
#pragma warning disable IDE0019 // Use pattern matching
@@ -51,6 +51,11 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express
return expression;
}
+ public override QueryExpression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, TArgument argument)
+ {
+ return expression;
+ }
+
public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument)
{
IImmutableList newTerms = VisitList(expression.Terms, argument);
@@ -175,31 +180,20 @@ public override QueryExpression VisitPagination(PaginationExpression expression,
return null;
}
- public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument)
+ public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
{
- ImmutableDictionary.Builder newTable =
- ImmutableDictionary.CreateBuilder();
+ IImmutableDictionary newElements = VisitDictionary(expression.Elements, argument);
- foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table)
- {
- if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet)
- {
- newTable[resourceType] = newSparseFieldSet;
- }
- }
-
- if (newTable.Count > 0)
- {
- var newExpression = new SparseFieldTableExpression(newTable.ToImmutable());
- return newExpression.Equals(expression) ? expression : newExpression;
- }
-
- return null;
+ var newExpression = SparseFieldSetExpression.Create(newElements);
+ return newExpression.Equals(expression) ? expression : newExpression;
}
- public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
+ public override QueryExpression VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, TArgument argument)
{
- return expression;
+ IImmutableDictionary newChildren = VisitDictionary(expression.Children, argument);
+
+ var newExpression = new SparseFieldSetElementExpression(expression.Field, newChildren);
+ return newExpression.Equals(expression) ? expression : newExpression;
}
public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument)
@@ -236,12 +230,7 @@ public override QueryExpression VisitInclude(IncludeExpression expression, TArgu
{
IImmutableSet newElements = VisitSet(expression.Elements, argument);
- if (newElements.Count == 0)
- {
- return IncludeExpression.Empty;
- }
-
- var newExpression = new IncludeExpression(newElements);
+ var newExpression = IncludeExpression.Create(newElements);
return newExpression.Equals(expression) ? expression : newExpression;
}
@@ -289,4 +278,22 @@ protected virtual IImmutableSet VisitSet(IImmutableSet
return setBuilder.ToImmutable();
}
+
+ protected virtual IImmutableDictionary VisitDictionary(IImmutableDictionary elements,
+ TArgument argument)
+ where TKey : notnull
+ where TExpression : QueryExpression
+ {
+ ImmutableDictionary.Builder builder = ImmutableDictionary.CreateBuilder();
+
+ foreach ((TKey key, TExpression value) in elements)
+ {
+ if (Visit(value, argument) is TExpression newValue)
+ {
+ builder[key] = newValue;
+ }
+ }
+
+ return builder.ToImmutable();
+ }
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
index a0472306f7..1c848a7288 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
@@ -44,6 +44,11 @@ public virtual TResult VisitNullConstant(NullConstantExpression expression, TArg
return DefaultVisit(expression, argument);
}
+ public virtual TResult VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, TArgument argument)
+ {
+ return DefaultVisit(expression, argument);
+ }
+
public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument)
{
return DefaultVisit(expression, argument);
@@ -94,12 +99,12 @@ public virtual TResult VisitAny(AnyExpression expression, TArgument argument)
return DefaultVisit(expression, argument);
}
- public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument)
+ public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
{
return DefaultVisit(expression, argument);
}
- public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
+ public virtual TResult VisitSparseFieldSetElement(SparseFieldSetElementExpression expression, TArgument argument)
{
return DefaultVisit(expression, argument);
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs
new file mode 100644
index 0000000000..b1e018c1d4
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Expressions/ScalarCollectionElementValueExpression.cs
@@ -0,0 +1,70 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Queries.Expressions;
+
+///
+/// Represents the element value of a scalar collection (_), resulting from text such as:
+///
+/// has(tags,equals(_,'Work'))
+///
+/// .
+///
+[PublicAPI]
+public sealed class ScalarCollectionElementValueExpression : IdentifierExpression
+{
+ private const string Symbol = "_";
+
+ ///
+ /// The CLR type of the scalar collection element, such as or .
+ ///
+ public Type ElementType { get; }
+
+ public ScalarCollectionElementValueExpression(Type elementType)
+ {
+ ArgumentNullException.ThrowIfNull(elementType);
+
+ ElementType = elementType;
+ }
+
+ ///
+ public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
+ {
+ return visitor.VisitScalarCollectionElementValue(this, argument);
+ }
+
+ ///
+ public override string ToString()
+ {
+ return Symbol;
+ }
+
+ ///
+ public override string ToFullString()
+ {
+ return ToString();
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj is null || GetType() != obj.GetType())
+ {
+ return false;
+ }
+
+ var other = (ScalarCollectionElementValueExpression)obj;
+
+ return ElementType == other.ElementType;
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return ElementType.GetHashCode();
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs
new file mode 100644
index 0000000000..ef01e27903
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetElementExpression.cs
@@ -0,0 +1,120 @@
+using System.Collections.Immutable;
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.Queries.Expressions;
+
+///
+/// Represents an element in a tree, resulting from text such as:
+///
+/// contact.name.firstName,contact.name.lastName
+///
+/// .
+///
+[PublicAPI]
+public class SparseFieldSetElementExpression : QueryExpression
+{
+ private static readonly ImmutableDictionary EmptyChildren =
+ ImmutableDictionary.Empty;
+
+ ///
+ /// The JSON:API field (attribute or relationship) to include.
+ ///
+ public ResourceFieldAttribute Field { get; }
+
+ ///
+ /// The direct children of this subtree for compound attributes, keyed by child field. An empty dictionary indicates that all viewable child attributes
+ /// are included.
+ ///
+ public IImmutableDictionary Children { get; }
+
+ public SparseFieldSetElementExpression(ResourceFieldAttribute field)
+ : this(field, EmptyChildren)
+ {
+ }
+
+ public SparseFieldSetElementExpression(ResourceFieldAttribute field, IImmutableDictionary children)
+ {
+ ArgumentNullException.ThrowIfNull(field);
+ ArgumentNullException.ThrowIfNull(children);
+
+ Field = field;
+ Children = children;
+ }
+
+ ///
+ public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
+ {
+ return visitor.VisitSparseFieldSetElement(this, argument);
+ }
+
+ ///
+ public override string ToString()
+ {
+ return InnerToString(false);
+ }
+
+ ///
+ public override string ToFullString()
+ {
+ return InnerToString(true);
+ }
+
+ private string InnerToString(bool toFullString)
+ {
+ return string.Join(',', Flatten(this, toFullString).Order());
+ }
+
+ internal static IEnumerable Flatten(SparseFieldSetElementExpression element, bool toFullString)
+ {
+ string fieldText = toFullString ? element.Field.ToFullString() : element.Field.ToString()!;
+
+ if (element.Children.Count == 0)
+ {
+ yield return fieldText;
+ }
+ else
+ {
+ foreach (SparseFieldSetElementExpression child in element.Children.Values)
+ {
+ foreach (string childText in Flatten(child, toFullString))
+ {
+ yield return $"{fieldText}.{childText}";
+ }
+ }
+ }
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj is null || GetType() != obj.GetType())
+ {
+ return false;
+ }
+
+ var other = (SparseFieldSetElementExpression)obj;
+
+ return Field.Equals(other.Field) && Children.DictionaryEquals(other.Children);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ var hashCode = new HashCode();
+ hashCode.Add(Field);
+
+ foreach ((ResourceFieldAttribute childField, SparseFieldSetElementExpression childElement) in Children.OrderBy(pair => pair.Key.PublicName))
+ {
+ hashCode.Add(childField);
+ hashCode.Add(childElement);
+ }
+
+ return hashCode.ToHashCode();
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs
index 7a4495bd83..9ce237ec09 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs
@@ -9,38 +9,68 @@ namespace JsonApiDotNetCore.Queries.Expressions;
///
/// firstName,lastName,articles
///
+/// , or:
+///
+/// contact.name.firstName,contact.address
+///
/// .
///
[PublicAPI]
public class SparseFieldSetExpression : QueryExpression
{
///
- /// The set of JSON:API fields to include. Chain format: a single field.
+ /// Indicates not to include any fields.
+ ///
+ public static readonly SparseFieldSetExpression Empty = new();
+
+ ///
+ /// The JSON:API fields included in this sparse fieldset tree, keyed by field (attribute or relationship).
///
- public IImmutableSet Fields { get; }
+ public IImmutableDictionary Elements { get; }
- public SparseFieldSetExpression(IImmutableSet fields)
+ public SparseFieldSetExpression(IImmutableDictionary elements)
{
- ArgumentGuard.NotNullNorEmpty(fields);
+ ArgumentGuard.NotNullNorEmpty(elements);
+
+ Elements = elements;
+ }
- Fields = fields;
+ private SparseFieldSetExpression()
+ {
+ Elements = ImmutableDictionary.Empty;
}
+ internal static SparseFieldSetExpression Create(IImmutableDictionary elements)
+ {
+ ArgumentNullException.ThrowIfNull(elements);
+
+ return elements.Count > 0 ? new SparseFieldSetExpression(elements) : Empty;
+ }
+
+ ///
public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
{
return visitor.VisitSparseFieldSet(this, argument);
}
+ ///
public override string ToString()
{
- return string.Join(',', Fields.Select(field => field.ToString()).Order());
+ return InnerToString(false);
}
+ ///
public override string ToFullString()
{
- return string.Join(',', Fields.Select(field => $"{field.ToFullString()}").Order());
+ return InnerToString(true);
+ }
+
+ private string InnerToString(bool toFullString)
+ {
+ return string.Join(',', Elements.Values.SelectMany(element => SparseFieldSetElementExpression.Flatten(element, toFullString)).Order());
}
+ ///
public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj))
@@ -55,16 +85,18 @@ public override bool Equals(object? obj)
var other = (SparseFieldSetExpression)obj;
- return Fields.SetEquals(other.Fields);
+ return Elements.DictionaryEquals(other.Elements);
}
+ ///
public override int GetHashCode()
{
var hashCode = new HashCode();
- foreach (ResourceFieldAttribute field in Fields)
+ foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in Elements.OrderBy(pair => pair.Key.PublicName))
{
hashCode.Add(field);
+ hashCode.Add(element);
}
return hashCode.ToHashCode();
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs
index 936071ffd1..9db7d9c8ff 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs
@@ -1,4 +1,3 @@
-using System.Collections.Immutable;
using System.Linq.Expressions;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
@@ -17,25 +16,47 @@ public static class SparseFieldSetExpressionExtensions
ArgumentNullException.ThrowIfNull(fieldSelector);
ArgumentNullException.ThrowIfNull(resourceGraph);
- SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet;
+ if (sparseFieldSet == null)
+ {
+ return null;
+ }
+
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+
+ foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector))
+ {
+ builder.IncludeChain(chain.Fields);
+ }
+
+ return builder.ToExpression();
+ }
+
+ public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field)
+ {
+ ArgumentNullException.ThrowIfNull(field);
- foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector))
+ if (sparseFieldSet == null)
{
- newSparseFieldSet = IncludeField(newSparseFieldSet, field);
+ return null;
}
- return newSparseFieldSet;
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+ builder.IncludeChain([field]);
+ return builder.ToExpression();
}
- private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude)
+ public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain)
{
- if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude))
+ ArgumentNullException.ThrowIfNull(fieldChain);
+
+ if (sparseFieldSet == null)
{
- return sparseFieldSet;
+ return null;
}
- IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude);
- return new SparseFieldSetExpression(newSparseFieldSet);
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+ builder.IncludeChain(fieldChain.Fields);
+ return builder.ToExpression();
}
public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet,
@@ -45,29 +66,50 @@ public static class SparseFieldSetExpressionExtensions
ArgumentNullException.ThrowIfNull(fieldSelector);
ArgumentNullException.ThrowIfNull(resourceGraph);
- SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet;
+ // Design tradeoff: When the sparse fieldset is null, it means all fields will be selected.
+ // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store.
+ // But later, when serializing the response, the sparse fieldset is first populated with all fields,
+ // so then the exclusion will actually be applied and the excluded field is not returned to the client.
+ if (sparseFieldSet == null)
+ {
+ return null;
+ }
- foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector))
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+
+ foreach (ResourceFieldChainExpression chain in resourceGraph.GetFieldChains(fieldSelector))
{
- newSparseFieldSet = ExcludeField(newSparseFieldSet, field);
+ builder.ExcludeChain(chain.Fields);
}
- return newSparseFieldSet;
+ return builder.ToExpression();
}
- private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude)
+ public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute field)
{
- // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected.
- // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store.
- // But later, when serializing the response, the sparse fieldset is first populated with all fields,
- // so then the exclusion will actually be applied and the excluded field is not returned to the client.
+ ArgumentNullException.ThrowIfNull(field);
+
+ if (sparseFieldSet == null)
+ {
+ return null;
+ }
+
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+ builder.ExcludeChain([field]);
+ return builder.ToExpression();
+ }
+
+ public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, ResourceFieldChainExpression fieldChain)
+ {
+ ArgumentNullException.ThrowIfNull(fieldChain);
- if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude))
+ if (sparseFieldSet == null)
{
- return sparseFieldSet;
+ return null;
}
- IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude);
- return new SparseFieldSetExpression(newSparseFieldSet);
+ SparseFieldTreeBuilder builder = SparseFieldTreeBuilder.FromExpression(sparseFieldSet);
+ builder.ExcludeChain(fieldChain.Fields);
+ return builder.ToExpression();
}
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs
deleted file mode 100644
index 0c84ae956e..0000000000
--- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System.Collections.Immutable;
-using System.Text;
-using JetBrains.Annotations;
-using JsonApiDotNetCore.Configuration;
-
-namespace JsonApiDotNetCore.Queries.Expressions;
-
-///
-/// Represents a lookup table of sparse fieldsets per resource type.
-///
-[PublicAPI]
-public class SparseFieldTableExpression : QueryExpression
-{
- ///
- /// The set of JSON:API fields to include, per resource type.
- ///
- public IImmutableDictionary Table { get; }
-
- public SparseFieldTableExpression(IImmutableDictionary table)
- {
- ArgumentGuard.NotNullNorEmpty(table);
-
- Table = table;
- }
-
- public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
- {
- return visitor.VisitSparseFieldTable(this, argument);
- }
-
- public override string ToString()
- {
- return InnerToString(false);
- }
-
- public override string ToFullString()
- {
- return InnerToString(true);
- }
-
- private string InnerToString(bool toFullString)
- {
- var builder = new StringBuilder();
-
- foreach ((ResourceType resourceType, SparseFieldSetExpression fieldSet) in Table)
- {
- if (builder.Length > 0)
- {
- builder.Append(',');
- }
-
- builder.Append(resourceType);
- builder.Append('(');
- builder.Append(toFullString ? fieldSet.ToFullString() : fieldSet.ToString());
- builder.Append(')');
- }
-
- return builder.ToString();
- }
-
- public override bool Equals(object? obj)
- {
- if (ReferenceEquals(this, obj))
- {
- return true;
- }
-
- if (obj is null || GetType() != obj.GetType())
- {
- return false;
- }
-
- var other = (SparseFieldTableExpression)obj;
-
- return Table.DictionaryEqual(other.Table);
- }
-
- public override int GetHashCode()
- {
- var hashCode = new HashCode();
-
- foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table)
- {
- hashCode.Add(resourceType);
- hashCode.Add(sparseFieldSet);
- }
-
- return hashCode.ToHashCode();
- }
-}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs
new file mode 100644
index 0000000000..5a19ae5dad
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTreeBuilder.cs
@@ -0,0 +1,168 @@
+using System.Collections.Immutable;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.Queries.Expressions;
+
+internal sealed class SparseFieldTreeBuilder
+{
+ private readonly SparseFieldTreeNode _root = new(null);
+
+ public static SparseFieldTreeBuilder FromExpression(SparseFieldSetExpression sparseFieldSet)
+ {
+ var builder = new SparseFieldTreeBuilder();
+
+ foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in sparseFieldSet.Elements)
+ {
+ builder._root.AddChild(field, SparseFieldTreeNode.FromElement(element));
+ }
+
+ return builder;
+ }
+
+ public void IncludeChain(IReadOnlyList chain)
+ {
+ if (chain.Count > 0)
+ {
+ _root.IncludeSubChain(chain, 0);
+ }
+ }
+
+ public void ExcludeChain(IReadOnlyList chain)
+ {
+ if (chain.Count > 0)
+ {
+ _root.ExcludeSubChain(chain, 0);
+ }
+ }
+
+ public SparseFieldSetExpression ToExpression()
+ {
+ return SparseFieldSetExpression.Create(_root.ToChildrenDictionary());
+ }
+
+ private sealed class SparseFieldTreeNode(ResourceFieldAttribute? field)
+ {
+ private readonly Dictionary _children = [];
+
+ private readonly ResourceFieldAttribute? _field = field;
+ private bool _selectAllChildren;
+
+ private bool IsEmpty => !_selectAllChildren && _children.Count == 0 && _field is AttrAttribute { IsCompound: true };
+
+ public void AddChild(ResourceFieldAttribute fieldToAdd, SparseFieldTreeNode node)
+ {
+ _children[fieldToAdd] = node;
+ }
+
+ public static SparseFieldTreeNode FromElement(SparseFieldSetElementExpression element)
+ {
+ var node = new SparseFieldTreeNode(element.Field)
+ {
+ _selectAllChildren = element.Children.Count == 0 && element.Field is AttrAttribute { IsCompound: true }
+ };
+
+ foreach ((ResourceFieldAttribute childField, SparseFieldSetElementExpression child) in element.Children)
+ {
+ node._children[childField] = FromElement(child);
+ }
+
+ return node;
+ }
+
+ public void IncludeSubChain(IReadOnlyList chain, int index)
+ {
+ if (index >= chain.Count)
+ {
+ // This node is the leaf of the included chain.
+ _selectAllChildren = _field is AttrAttribute { IsCompound: true };
+ _children.Clear();
+ return;
+ }
+
+ if (_selectAllChildren)
+ {
+ // Parent already selects all child fields, so deeper path is redundant.
+ return;
+ }
+
+ ResourceFieldAttribute childField = chain[index];
+
+ if (!_children.TryGetValue(childField, out SparseFieldTreeNode? childNode))
+ {
+ childNode = new SparseFieldTreeNode(childField)
+ {
+ _selectAllChildren = index == chain.Count - 1 && childField is AttrAttribute { IsCompound: true }
+ };
+
+ _children[childField] = childNode;
+ }
+
+ childNode.IncludeSubChain(chain, index + 1);
+ }
+
+ public void ExcludeSubChain(IReadOnlyList chain, int index)
+ {
+ if (_selectAllChildren && _field is AttrAttribute { CompoundType: not null } compoundAttr)
+ {
+ ExpandAllChildren(compoundAttr.CompoundType);
+ }
+
+ ResourceFieldAttribute childField = chain[index];
+
+ if (!_children.TryGetValue(childField, out SparseFieldTreeNode? childNode))
+ {
+ return;
+ }
+
+ if (index == chain.Count - 1)
+ {
+ _children.Remove(childField);
+ return;
+ }
+
+ childNode.ExcludeSubChain(chain, index + 1);
+
+ if (childNode.IsEmpty)
+ {
+ _children.Remove(childField);
+ }
+ }
+
+ private void ExpandAllChildren(CompoundType compoundType)
+ {
+ _selectAllChildren = false;
+
+ foreach (AttrAttribute attr in compoundType.Attributes)
+ {
+ if (!attr.IsViewBlocked() && !_children.ContainsKey(attr))
+ {
+ _children[attr] = new SparseFieldTreeNode(attr)
+ {
+ _selectAllChildren = attr.IsCompound
+ };
+ }
+ }
+ }
+
+ public ImmutableDictionary ToChildrenDictionary()
+ {
+ ImmutableDictionary.Builder builder =
+ ImmutableDictionary.CreateBuilder();
+
+ foreach ((ResourceFieldAttribute childField, SparseFieldTreeNode childNode) in _children)
+ {
+ builder.Add(childField, childNode.ToElement());
+ }
+
+ return builder.ToImmutable();
+ }
+
+ private SparseFieldSetElementExpression ToElement()
+ {
+ return _selectAllChildren || _children.Count == 0
+ ? new SparseFieldSetElementExpression(_field!)
+ : new SparseFieldSetElementExpression(_field!, ToChildrenDictionary());
+ }
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs
index 246b1ce8e1..2f27a85587 100644
--- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs
+++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs
@@ -58,20 +58,27 @@ internal void WriteSelection(IndentingStringWriter writer, bool toFullString)
foreach (ResourceType type in GetResourceTypes())
{
writer.WriteLine($"{nameof(FieldSelectors)}<{type.ClrType.Name}>");
- WriterSelectors(writer, toFullString, type);
+ WriteSelectors(writer, toFullString, type);
}
}
}
- private void WriterSelectors(IndentingStringWriter writer, bool toFullString, ResourceType type)
+ private void WriteSelectors(IndentingStringWriter writer, bool toFullString, ResourceType type)
{
+ FieldSelectors selectors = GetOrCreateSelectors(type);
+
using (writer.Indent())
{
- foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in GetOrCreateSelectors(type))
+ foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in selectors)
{
if (nextLayer == null)
{
writer.WriteLine(toFullString ? field.ToFullString() : field.ToString());
+
+ if (field is AttrAttribute attr && selectors.GetChildSelectors(attr) is { } childSelectors)
+ {
+ WriteChildSelectors(writer, toFullString, childSelectors);
+ }
}
else
{
@@ -81,4 +88,20 @@ private void WriterSelectors(IndentingStringWriter writer, bool toFullString, Re
}
}
}
+
+ private static void WriteChildSelectors(IndentingStringWriter writer, bool toFullString, FieldSelectors childSelectors)
+ {
+ using (writer.Indent())
+ {
+ foreach (ResourceFieldAttribute field in childSelectors.Keys)
+ {
+ writer.WriteLine(toFullString ? field.ToFullString() : field.ToString());
+
+ if (field is AttrAttribute attr && childSelectors.GetChildSelectors(attr) is { } nextChildren)
+ {
+ WriteChildSelectors(writer, toFullString, nextChildren);
+ }
+ }
+ }
+ }
}
diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs
index d9ab815638..6f921fd6bd 100644
--- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs
+++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs
@@ -1,3 +1,4 @@
+using System.Collections.Immutable;
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources.Annotations;
@@ -10,8 +11,21 @@ namespace JsonApiDotNetCore.Queries;
[PublicAPI]
public sealed class FieldSelectors : Dictionary
{
+ private Dictionary? _childSelectors;
+
public bool IsEmpty => Count == 0;
+ ///
+ /// Indicates whether any child selectors have been configured for compound attributes.
+ ///
+ public bool HasChildSelectors => _childSelectors is { Count: > 0 };
+
+ ///
+ /// The configured child selectors keyed by their parent compound attribute.
+ ///
+ public IReadOnlyDictionary ChildSelectors =>
+ _childSelectors ?? (IReadOnlyDictionary)ImmutableDictionary.Empty;
+
public bool ContainsReadOnlyAttribute
{
get
@@ -28,6 +42,32 @@ public bool ContainsOnlyRelationships
}
}
+ ///
+ /// Gets child selectors for the specified compound attribute, or null if not present.
+ ///
+ public FieldSelectors? GetChildSelectors(AttrAttribute compoundAttribute)
+ {
+ ArgumentNullException.ThrowIfNull(compoundAttribute);
+ return _childSelectors?.GetValueOrDefault(compoundAttribute);
+ }
+
+ ///
+ /// Gets or creates child selectors for the specified compound attribute.
+ ///
+ public FieldSelectors GetOrCreateChildSelectors(AttrAttribute compoundAttribute)
+ {
+ ArgumentNullException.ThrowIfNull(compoundAttribute);
+ _childSelectors ??= new Dictionary();
+
+ if (!_childSelectors.TryGetValue(compoundAttribute, out FieldSelectors? childSelectors))
+ {
+ childSelectors = new FieldSelectors();
+ _childSelectors[compoundAttribute] = childSelectors;
+ }
+
+ return childSelectors;
+ }
+
public bool ContainsField(ResourceFieldAttribute field)
{
ArgumentNullException.ThrowIfNull(field);
@@ -62,10 +102,11 @@ public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer q
public void RemoveAttributes()
{
- while (this.Any(pair => pair.Key is AttrAttribute))
+ foreach (ResourceFieldAttribute field in Keys.Where(key => key is AttrAttribute).ToArray())
{
- ResourceFieldAttribute field = this.First(pair => pair.Key is AttrAttribute).Key;
Remove(field);
}
+
+ _childSelectors?.Clear();
}
}
diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
index da65dbedc5..9c7ebd472f 100644
--- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs
@@ -11,13 +11,6 @@ namespace JsonApiDotNetCore.Queries;
///
public interface IQueryLayerComposer
{
- ///
- /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint.
- ///
- [Obsolete("This method is no longer used and will be removed in a future version.")]
- // ReSharper disable once UnusedMemberInSuper.Global
- FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType);
-
///
/// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint.
///
diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs
deleted file mode 100644
index 22046d3bca..0000000000
--- a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.Collections.Immutable;
-using JsonApiDotNetCore.Configuration;
-using JsonApiDotNetCore.Resources;
-using JsonApiDotNetCore.Resources.Annotations;
-
-namespace JsonApiDotNetCore.Queries;
-
-///
-/// Takes sparse fieldsets from s and invokes
-/// on them.
-///
-///
-/// This cache ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The
-/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which
-/// fields to write to the response body.
-///
-public interface ISparseFieldSetCache
-{
- ///
- /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields.
- ///
- IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType);
-
- ///
- /// Gets the set of attributes to retrieve from the underlying data store for relationship endpoints. This always returns 'id', along with any additional
- /// attributes from resource definition callback.
- ///
- IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType);
-
- ///
- /// Gets the evaluated set of sparse fields to serialize into the response body.
- ///
- IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType);
-
- ///
- /// Resets the cached results from resource definition callbacks.
- ///
- void Reset();
-}
diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs
new file mode 100644
index 0000000000..080684f9f6
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetEvaluator.cs
@@ -0,0 +1,32 @@
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Queries.Expressions;
+using JsonApiDotNetCore.Resources;
+
+namespace JsonApiDotNetCore.Queries;
+
+///
+/// Takes sparse fieldsets from s and invokes
+/// on them.
+///
+///
+/// This service ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The
+/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which
+/// fields to write to the response body.
+///
+public interface ISparseFieldSetEvaluator
+{
+ ///
+ /// Gets the sparse fieldset to retrieve from the underlying data store, or null to retrieve all fields.
+ ///
+ SparseFieldSetExpression? GetSparseFieldSetForQuery(ResourceType resourceType);
+
+ ///
+ /// Gets the evaluated sparse fieldset to serialize into the response body.
+ ///
+ SparseFieldSetExpression GetSparseFieldSetForSerializer(ResourceType resourceType);
+
+ ///
+ /// Resets the cached results from resource definition callbacks.
+ ///
+ void Reset();
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs
new file mode 100644
index 0000000000..7cb242d212
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Parsing/AttributeContainerFilterScope.cs
@@ -0,0 +1,29 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Configuration;
+
+namespace JsonApiDotNetCore.Queries.Parsing;
+
+///
+/// Represents a filter scope on a resource or compound type container.
+///
+[PublicAPI]
+public sealed class AttributeContainerFilterScope : FilterScope
+{
+ ///
+ /// Gets the container in scope.
+ ///
+ public IAttributeContainer Container { get; }
+
+ public AttributeContainerFilterScope(IAttributeContainer container)
+ {
+ ArgumentNullException.ThrowIfNull(container);
+
+ Container = container;
+ }
+
+ ///
+ public override string? ToString()
+ {
+ return Container.ToString();
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs
index 3001c19296..e5416f9183 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.Diagnostics;
using Humanizer;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
@@ -34,21 +35,30 @@ public class FilterParser : QueryExpressionParser, IFilterParser
];
private readonly IResourceFactory _resourceFactory;
- private readonly Stack _resourceTypeStack = new();
+ private readonly Stack _scopeStack = new();
+
+ protected QueryParseChoices FieldOrElementChoice =>
+ CurrentScope switch
+ {
+ ScalarCollectionElementFilterScope => QueryParseChoices.Underscore,
+ AttributeContainerFilterScope => QueryParseChoices.FieldName,
+ _ => throw new UnreachableException()
+ };
///
- /// Gets the resource type currently in scope. Call to temporarily change the current resource type.
+ /// Gets the current filter scope. Call or to temporarily change the current
+ /// scope.
///
- protected ResourceType ResourceTypeInScope
+ protected FilterScope CurrentScope
{
get
{
- if (_resourceTypeStack.Count == 0)
+ if (_scopeStack.Count == 0)
{
- throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first.");
+ throw new InvalidOperationException($"No filter scope is currently active. Call {nameof(Parse)}() first.");
}
- return _resourceTypeStack.Peek();
+ return _scopeStack.Peek();
}
}
@@ -66,19 +76,17 @@ public FilterExpression Parse(string source, ResourceType resourceType)
Tokenize(source);
- _resourceTypeStack.Clear();
- FilterExpression expression;
+ _scopeStack.Clear();
- using (InScopeOfResourceType(resourceType))
+ using (InScopeOfContainer(resourceType))
{
- expression = ParseFilter();
+ FilterExpression expression = ParseFilter();
AssertTokenStackIsEmpty();
- }
-
- AssertResourceTypeStackIsEmpty();
+ AssertFilterScopeStackHasOnlyRoot();
- return expression;
+ return expression;
+ }
}
protected virtual bool IsFunction(string name)
@@ -90,27 +98,26 @@ protected virtual bool IsFunction(string name)
protected virtual FunctionExpression ParseFunction()
{
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text)
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count })
{
- switch (nextToken.Value)
- {
- case Keywords.Count:
- {
- return ParseCount();
- }
- }
+ return ParseCount();
}
return ParseFilter();
}
- private CountExpression ParseCount()
+ protected virtual CountExpression ParseCount()
{
+ if (CurrentScope is not AttributeContainerFilterScope containerScope)
+ {
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException("Function 'count' cannot be used on scalar collection elements.", position);
+ }
+
EatText(Keywords.Count);
EatSingleCharacterToken(TokenKind.OpenParen);
- ResourceFieldChainExpression targetCollection =
- ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null);
+ ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, containerScope.Container);
EatSingleCharacterToken(TokenKind.CloseParen);
@@ -162,7 +169,7 @@ protected virtual FilterExpression ParseFilter()
}
int position = GetNextTokenPositionOrEnd();
- throw new QueryParseException("Filter function expected.", position);
+ throw new QueryParseException(QueryParseChoices.FilterFunction, position);
}
protected virtual NotExpression ParseNot()
@@ -194,10 +201,8 @@ protected virtual LogicalExpression ParseLogical(string operatorName)
term = ParseFilter();
termsBuilder.Add(term);
- while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
+ while (TryEatSingleCharacterToken(TokenKind.Comma))
{
- EatSingleCharacterToken(TokenKind.Comma);
-
term = ParseFilter();
termsBuilder.Add(term);
}
@@ -213,43 +218,82 @@ protected virtual ComparisonExpression ParseComparison(string operatorName)
ArgumentException.ThrowIfNullOrEmpty(operatorName);
var comparisonOperator = Enum.Parse(operatorName.Pascalize());
+ bool isEqualityComparison = comparisonOperator == ComparisonOperator.Equals;
EatText(operatorName);
EatSingleCharacterToken(TokenKind.OpenParen);
- QueryExpression leftTerm = ParseComparisonLeftTerm(comparisonOperator);
+ QueryExpression leftTerm = ParseComparisonLeftTerm(isEqualityComparison);
EatSingleCharacterToken(TokenKind.Comma);
- QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm);
+ QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm, isEqualityComparison);
EatSingleCharacterToken(TokenKind.CloseParen);
return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm);
}
- private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator)
+ private QueryExpression ParseComparisonLeftTerm(bool isEqualityComparison)
{
if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!))
{
return ParseFunction();
}
- // Allow equality comparison of a to-one relationship with null.
- FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals
- ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne
- : BuiltInPatterns.ToOneChainEndingInAttribute;
+ if (CurrentScope is ScalarCollectionElementFilterScope collectionScope)
+ {
+ ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope);
- return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected.");
+ if (elementValue != null)
+ {
+ return elementValue;
+ }
+ }
+ else if (CurrentScope is AttributeContainerFilterScope containerScope)
+ {
+ if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token))
+ {
+ // Allow equality comparison of a to-one relationship or attribute with null.
+ FieldChainPattern pattern = isEqualityComparison
+ ? BuiltInPatterns.ToOneOrCompoundChainEndingInToOneOrAttribute
+ : BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute;
+
+ return ParseFieldChain(pattern, containerScope.Container);
+ }
+ }
+
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position);
+ }
+
+ ///
+ /// Attempts to parse the underscore (_), which represents the current scalar collection element.
+ ///
+ protected ScalarCollectionElementValueExpression? TryParseScalarCollectionElement(ScalarCollectionElementFilterScope collectionScope)
+ {
+ ArgumentNullException.ThrowIfNull(collectionScope);
+
+ if (TryEatSingleCharacterToken(TokenKind.Underscore))
+ {
+ return new ScalarCollectionElementValueExpression(collectionScope.ElementType);
+ }
+
+ return null;
}
- private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm)
+ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm, bool isEqualityComparison)
{
if (leftTerm is ResourceFieldChainExpression leftFieldChain)
{
ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1];
if (leftLastField is HasOneAttribute)
+ {
+ return ParseToOneRelationshipComparisonRightTerm();
+ }
+
+ if (leftLastField is AttrAttribute { IsCompound: true } or AttrAttribute { IsCollection: true })
{
return ParseNull();
}
@@ -257,29 +301,66 @@ private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm)
var leftAttribute = (AttrAttribute)leftLastField;
ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(leftAttribute);
- return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter);
+ return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter, isEqualityComparison);
}
if (leftTerm is FunctionExpression leftFunction)
{
ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(leftFunction.ReturnType);
- return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter);
+ return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter, isEqualityComparison);
+ }
+
+ if (leftTerm is ScalarCollectionElementValueExpression collectionElement)
+ {
+ ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(collectionElement.ElementType);
+ return ParseTypedComparisonRightTerm(collectionElement.ElementType, constantValueConverter, isEqualityComparison);
}
- throw new InvalidOperationException(
- $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'.");
+ throw new UnreachableException();
}
- private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter)
+ private QueryExpression ParseToOneRelationshipComparisonRightTerm()
{
- bool allowNull = RuntimeTypeConverter.CanContainNull(leftType);
+ if (CurrentScope is not AttributeContainerFilterScope containerScope)
+ {
+ throw new UnreachableException();
+ }
- string errorMessage =
- allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected.";
+ if (TokenStack.TryPeek(out Token? nextToken))
+ {
+ if (IsNullConstantToken(nextToken))
+ {
+ TokenStack.Pop();
+ return NullConstantExpression.Instance;
+ }
+
+ if (nextToken.Kind == TokenKind.Text && !IsFunction(nextToken.Value!))
+ {
+ return ParseFieldChain(BuiltInPatterns.ToOneChain, containerScope.Container);
+ }
+ }
+
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.Null, position);
+ }
+
+ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter, bool isEqualityComparison)
+ {
+ // Always allowing to compare equality with null makes the JSON:API contract resilient to internal implementation details.
+ // This unifies the behavior for all attributes types (scalar/compound/collection) across nullability of reference/value types.
+ // Comparing a non-nullable value type to null is a harmless predicate that will simply never match any database rows.
+ bool allowNull = isEqualityComparison || RuntimeTypeConverter.CanContainNull(leftType);
+
+ QueryParseChoices rightTermChoices = QueryParseChoices.Function | FieldOrElementChoice | QueryParseChoices.QuotedText;
+
+ if (allowNull)
+ {
+ rightTermChoices |= QueryParseChoices.Null;
+ }
if (TokenStack.TryPeek(out Token? nextToken))
{
- if (nextToken is { Kind: TokenKind.QuotedText })
+ if (nextToken.Kind == TokenKind.QuotedText)
{
TokenStack.Pop();
@@ -287,30 +368,43 @@ private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantVal
return new LiteralConstantExpression(constantValue, nextToken.Value!);
}
- if (nextToken.Kind == TokenKind.Text)
+ if (CurrentScope is ScalarCollectionElementFilterScope collectionScope)
{
- if (nextToken.Value == Keywords.Null)
+ ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope);
+
+ if (elementValue != null)
{
- if (!allowNull)
- {
- throw new QueryParseException(errorMessage, nextToken.Position);
- }
+ return elementValue;
+ }
+ }
- TokenStack.Pop();
- return NullConstantExpression.Instance;
+ if (IsNullConstantToken(nextToken))
+ {
+ if (!allowNull)
+ {
+ throw new QueryParseException(rightTermChoices, nextToken.Position);
}
+ TokenStack.Pop();
+ return NullConstantExpression.Instance;
+ }
+
+ if (nextToken.Kind == TokenKind.Text)
+ {
if (IsFunction(nextToken.Value!))
{
return ParseFunction();
}
- return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage);
+ if (CurrentScope is AttributeContainerFilterScope containerScope)
+ {
+ return ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, containerScope.Container);
+ }
}
}
int position = GetNextTokenPositionOrEnd();
- throw new QueryParseException(errorMessage, position);
+ throw new QueryParseException(rightTermChoices, position);
}
protected virtual MatchTextExpression ParseTextMatch(string operatorName)
@@ -341,26 +435,58 @@ private QueryExpression ParseTextMatchLeftTerm()
if (targetFunction.ReturnType != typeof(string))
{
- throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position);
+ string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(targetFunction.ReturnType);
+ throw new QueryParseException($"Function returns type '{actualTypeName}' while type 'String' was expected.", nextToken.Position);
}
return targetFunction;
}
- int chainStartPosition = GetNextTokenPositionOrEnd();
+ if (CurrentScope is ScalarCollectionElementFilterScope collectionScope)
+ {
+ int elementPosition = GetNextTokenPositionOrEnd();
+ ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope);
+
+ if (elementValue != null)
+ {
+ if (elementValue.ElementType != typeof(string))
+ {
+ string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(elementValue.ElementType);
+ throw new QueryParseException($"Element is of type '{actualTypeName}' while type 'String' was expected.", elementPosition);
+ }
- ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None,
- ResourceTypeInScope, null);
+ return elementValue;
+ }
- var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
+ throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, elementPosition);
+ }
- if (targetAttribute.Property.PropertyType != typeof(string))
+ if (CurrentScope is AttributeContainerFilterScope containerScope)
{
- int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain);
- throw new QueryParseException("Attribute of type 'String' expected.", position);
+ if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token))
+ {
+ int chainStartPosition = GetNextTokenPositionOrEnd();
+
+ ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute,
+ containerScope.Container);
+
+ var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
+
+ if (targetAttribute.Property.PropertyType != typeof(string))
+ {
+ int attrPosition = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain);
+ string actualTypeName = RuntimeTypeConverter.GetFriendlyTypeName(targetAttribute.Property.PropertyType);
+
+ throw new QueryParseException($"Attribute '{targetAttribute.PublicName}' is of type '{actualTypeName}' while type 'String' was expected.",
+ attrPosition);
+ }
+
+ return targetAttributeChain;
+ }
}
- return targetAttributeChain;
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position);
}
protected virtual AnyExpression ParseAny()
@@ -378,10 +504,8 @@ protected virtual AnyExpression ParseAny()
LiteralConstantExpression constant = ParseConstant(constantValueConverter);
constantsBuilder.Add(constant);
- while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
+ while (TryEatSingleCharacterToken(TokenKind.Comma))
{
- EatSingleCharacterToken(TokenKind.Comma);
-
constant = ParseConstant(constantValueConverter);
constantsBuilder.Add(constant);
}
@@ -399,38 +523,79 @@ protected virtual AnyExpression ParseAny()
{
FunctionExpression targetFunction = ParseFunction();
- Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType);
- return (targetFunction, functionConverterFactory);
+ Func converterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType);
+ return (targetFunction, converterFactory);
}
- ResourceFieldChainExpression targetAttributeChain =
- ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null);
+ if (CurrentScope is ScalarCollectionElementFilterScope collectionScope)
+ {
+ ScalarCollectionElementValueExpression? elementValue = TryParseScalarCollectionElement(collectionScope);
+
+ if (elementValue != null)
+ {
+ Func converterFactory = () => GetConstantValueConverterForType(elementValue.ElementType);
+ return (elementValue, converterFactory);
+ }
+ }
+ else if (CurrentScope is AttributeContainerFilterScope containerScope)
+ {
+ if (TokenStack.TryPeek(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token))
+ {
+ ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute,
+ containerScope.Container);
+
+ var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
- var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
+ Func converterFactory = () => GetConstantValueConverterForAttribute(targetAttribute);
+ return (targetAttributeChain, converterFactory);
+ }
+ }
- Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute);
- return (targetAttributeChain, attributeConverterFactory);
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.Function | FieldOrElementChoice, position);
}
protected virtual HasExpression ParseHas()
{
+ if (CurrentScope is not AttributeContainerFilterScope containerScope)
+ {
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException("Function 'has' cannot be used on scalar collection elements.", position);
+ }
+
EatText(Keywords.Has);
EatSingleCharacterToken(TokenKind.OpenParen);
- ResourceFieldChainExpression targetCollection =
- ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null);
+ ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, containerScope.Container);
FilterExpression? filter = null;
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
+ if (TryEatSingleCharacterToken(TokenKind.Comma))
{
- EatSingleCharacterToken(TokenKind.Comma);
-
- var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1];
+ ResourceFieldAttribute lastField = targetCollection.Fields[^1];
- using (InScopeOfResourceType(hasManyRelationship.RightType))
+ if (lastField is HasManyAttribute hasManyRelationship)
{
- filter = ParseFilter();
+ using (InScopeOfContainer(hasManyRelationship.RightType))
+ {
+ filter = ParseFilter();
+ }
+ }
+ else if (lastField is AttrAttribute { IsCompound: true, IsCollection: true } compoundCollectionAttr)
+ {
+ using (InScopeOfContainer(compoundCollectionAttr.CompoundType!))
+ {
+ filter = ParseFilter();
+ }
+ }
+ else if (lastField is AttrAttribute { IsCompound: false, IsCollection: true } scalarCollectionAttr)
+ {
+ Type elementType = CollectionConverter.Instance.FindCollectionElementType(scalarCollectionAttr.Property.PropertyType)!;
+
+ using (InScopeOfScalarCollection(elementType))
+ {
+ filter = ParseFilter();
+ }
}
}
@@ -441,14 +606,20 @@ protected virtual HasExpression ParseHas()
protected virtual IsTypeExpression ParseIsType()
{
+ if (CurrentScope is not AttributeContainerFilterScope { Container: ResourceType resourceTypeInScope })
+ {
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException("Function 'isType' can only be used on resource types.", position);
+ }
+
EatText(Keywords.IsType);
EatSingleCharacterToken(TokenKind.OpenParen);
- ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain();
+ ResourceFieldChainExpression? targetToOneRelationship = TryParseCommaSeparatedSequenceOfToOneRelationshipChains(resourceTypeInScope);
EatSingleCharacterToken(TokenKind.Comma);
- ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope;
+ ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : resourceTypeInScope;
ResourceType derivedType = ParseDerivedType(baseType);
FilterExpression? child = TryParseFilterInIsType(derivedType);
@@ -458,14 +629,23 @@ protected virtual IsTypeExpression ParseIsType()
return new IsTypeExpression(targetToOneRelationship, derivedType, child);
}
- private ResourceFieldChainExpression? TryParseToOneRelationshipChain()
+ private ResourceFieldChainExpression? TryParseCommaSeparatedSequenceOfToOneRelationshipChains(ResourceType resourceTypeInScope)
{
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
+ if (TokenStack.TryPeek(out Token? nextToken))
{
- return null;
+ if (nextToken.Kind == TokenKind.Comma)
+ {
+ return null;
+ }
+
+ if (nextToken.Kind == TokenKind.Text && !IsNullConstantToken(nextToken))
+ {
+ return ParseFieldChain(BuiltInPatterns.ToOneChain, resourceTypeInScope);
+ }
}
- return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected.");
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.ToOneRelationshipName | QueryParseChoices.Comma, position);
}
private ResourceType ParseDerivedType(ResourceType baseType)
@@ -478,7 +658,7 @@ private ResourceType ParseDerivedType(ResourceType baseType)
return ResolveDerivedType(baseType, derivedTypeName, token.Position);
}
- throw new QueryParseException("Resource type expected.", position);
+ throw new QueryParseException(QueryParseChoices.ResourceType, position);
}
private static ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName, int position)
@@ -517,11 +697,9 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der
{
FilterExpression? filter = null;
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
+ if (TryEatSingleCharacterToken(TokenKind.Comma))
{
- EatSingleCharacterToken(TokenKind.Comma);
-
- using (InScopeOfResourceType(derivedType))
+ using (InScopeOfContainer(derivedType))
{
filter = ParseFilter();
}
@@ -530,8 +708,10 @@ private static ResourceType ResolveDerivedType(ResourceType baseType, string der
return filter;
}
- private LiteralConstantExpression ParseConstant(ConstantValueConverter constantValueConverter)
+ protected virtual LiteralConstantExpression ParseConstant(ConstantValueConverter constantValueConverter)
{
+ ArgumentNullException.ThrowIfNull(constantValueConverter);
+
int position = GetNextTokenPositionOrEnd();
if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText)
@@ -540,19 +720,19 @@ private LiteralConstantExpression ParseConstant(ConstantValueConverter constantV
return new LiteralConstantExpression(constantValue, token.Value!);
}
- throw new QueryParseException("Value between quotes expected.", position);
+ throw new QueryParseException(QueryParseChoices.QuotedText, position);
}
- private NullConstantExpression ParseNull()
+ protected virtual NullConstantExpression ParseNull()
{
int position = GetNextTokenPositionOrEnd();
- if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null })
+ if (TokenStack.TryPop(out Token? token) && IsNullConstantToken(token))
{
return NullConstantExpression.Instance;
}
- throw new QueryParseException("null expected.", position);
+ throw new QueryParseException(QueryParseChoices.Null, position);
}
protected virtual ConstantValueConverter GetConstantValueConverterForType(Type destinationType)
@@ -568,20 +748,20 @@ protected virtual ConstantValueConverter GetConstantValueConverterForType(Type d
catch (FormatException exception)
{
string destinationTypeName = RuntimeTypeConverter.GetFriendlyTypeName(destinationType);
- throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationTypeName}'.", position, exception);
+ throw new QueryParseException($"Failed to convert '{stringValue}' to type '{destinationTypeName}'.", position, exception);
}
};
}
private ConstantValueConverter GetConstantValueConverterForAttribute(AttrAttribute attribute)
{
- if (attribute is { Property.Name: nameof(Identifiable<>.Id) })
+ if (attribute is { Property.Name: nameof(Identifiable<>.Id), ContainingType: ResourceType resourceType })
{
return (stringValue, position) =>
{
try
{
- return DeObfuscateStringId(attribute.Type, stringValue);
+ return DeObfuscateStringId(resourceType, stringValue);
}
catch (JsonApiException exception)
{
@@ -612,32 +792,60 @@ protected override void ValidateField(ResourceFieldAttribute field, int position
}
///
- /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed.
+ /// Changes the current filter scope to the specified attribute container (resource type or compound type) and restores the original scope when the
+ /// return value is disposed.
///
- protected IDisposable InScopeOfResourceType(ResourceType resourceType)
+ protected IDisposable InScopeOfContainer(IAttributeContainer container)
{
- ArgumentNullException.ThrowIfNull(resourceType);
+ ArgumentNullException.ThrowIfNull(container);
+
+ var scope = new AttributeContainerFilterScope(container);
+ return PushFilterScope(scope);
+ }
+
+ ///
+ /// Changes the current filter scope to the specified scalar attribute collection type and restores the original scope when the return value is disposed.
+ ///
+ protected IDisposable InScopeOfScalarCollection(Type collectionElementType)
+ {
+ ArgumentNullException.ThrowIfNull(collectionElementType);
- _resourceTypeStack.Push(resourceType);
- return new PopResourceTypeOnDispose(_resourceTypeStack);
+ var scope = new ScalarCollectionElementFilterScope(collectionElementType);
+ return PushFilterScope(scope);
}
- private void AssertResourceTypeStackIsEmpty()
+ private PopFilterScopeOnDispose PushFilterScope(FilterScope scope)
{
- if (_resourceTypeStack.Count > 0)
+ _scopeStack.Push(scope);
+ return new PopFilterScopeOnDispose(_scopeStack);
+ }
+
+ private void AssertFilterScopeStackHasOnlyRoot()
+ {
+ if (_scopeStack.Count > 1)
{
- throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " +
- $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}().");
+ FilterScope scopeAtTopOfStack = _scopeStack.Peek();
+
+ if (scopeAtTopOfStack is AttributeContainerFilterScope containerScope)
+ {
+ string containerType = containerScope.Container is ResourceType ? "resource type" : "compound type";
+
+ throw new InvalidOperationException($"There is still a {containerType} in scope after parsing has completed. " +
+ $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfContainer)}().");
+ }
+
+ throw new InvalidOperationException("There is still a scalar collection element type in scope after parsing has completed. " +
+ $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfScalarCollection)}().");
}
}
- private sealed class PopResourceTypeOnDispose(Stack resourceTypeStack) : IDisposable
+ private sealed class PopFilterScopeOnDispose(Stack scopeStack) : IDisposable
{
- private readonly Stack _resourceTypeStack = resourceTypeStack;
+ private readonly Stack _scopeStack = scopeStack;
public void Dispose()
{
- _resourceTypeStack.Pop();
+ _scopeStack.Pop();
}
}
}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs
new file mode 100644
index 0000000000..5ad6402fdb
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterScope.cs
@@ -0,0 +1,14 @@
+namespace JsonApiDotNetCore.Queries.Parsing;
+
+///
+/// Represents the lexical scope within a filter expression.
+///
+public abstract class FilterScope
+{
+ ///
+ /// This constructor prevents external inheritance.
+ ///
+ private protected FilterScope()
+ {
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs
index acec82b8f2..721af2ebe1 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs
@@ -17,5 +17,5 @@ public interface ISparseFieldSetParser
///
/// The resource type used to lookup JSON:API fields that are referenced in .
///
- SparseFieldSetExpression? Parse(string source, ResourceType resourceType);
+ SparseFieldSetExpression Parse(string source, ResourceType resourceType);
}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs
index f8338c93c2..cd573bfd5c 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs
@@ -60,7 +60,9 @@ private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression include
if (parentChain.Count > maximumDepth)
{
string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName));
- throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}.", position);
+
+ throw new QueryParseException(
+ $"Including '{path}' has a depth of {parentChain.Count}, which exceeds the maximum inclusion depth of {maximumDepth}.", position);
}
foreach (IncludeElementExpression child in includeElement.Children)
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs
index d6199dade7..688f405ce3 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs
@@ -56,45 +56,40 @@ protected virtual PaginationElementQueryStringValueExpression ParsePaginationEle
return new PaginationElementQueryStringValueExpression(null, number.Value, position);
}
- IncludeExpression scope = ParseRelationshipChainEndingInToMany(resourceType, "Number or relationship name expected.");
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && !IsNullConstantToken(nextToken))
+ {
+ IncludeExpression scope = ParseRelationshipChainEndingInToMany(resourceType);
- EatSingleCharacterToken(TokenKind.Colon);
+ EatSingleCharacterToken(TokenKind.Colon);
- position = GetNextTokenPositionOrEnd();
- number = TryParseNumber();
+ position = GetNextTokenPositionOrEnd();
+ number = TryParseNumber();
- if (number == null)
- {
- throw new QueryParseException("Number expected.", position);
+ if (number == null)
+ {
+ throw new QueryParseException(QueryParseChoices.Number, position);
+ }
+
+ return new PaginationElementQueryStringValueExpression(scope, number.Value, position);
}
- return new PaginationElementQueryStringValueExpression(scope, number.Value, position);
+ throw new QueryParseException(QueryParseChoices.Number | QueryParseChoices.ToManyRelationshipName, position);
}
private int? TryParseNumber()
{
- if (TokenStack.TryPeek(out Token? nextToken))
- {
- int number;
-
- if (nextToken.Kind == TokenKind.Minus)
- {
- TokenStack.Pop();
- int position = GetNextTokenPositionOrEnd();
+ bool isNegative = TryEatSingleCharacterToken(TokenKind.Minus);
- if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number))
- {
- return -number;
- }
-
- throw new QueryParseException("Digits expected.", position);
- }
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out int number))
+ {
+ TokenStack.Pop();
+ return isNegative ? -number : number;
+ }
- if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number))
- {
- TokenStack.Pop();
- return number;
- }
+ if (isNegative)
+ {
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(QueryParseChoices.Number, position);
}
return null;
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs
index 46fa2c8273..b57e8a31fa 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs
@@ -1,9 +1,9 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
using System.Text;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
-using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.QueryStrings.FieldChains;
using JsonApiDotNetCore.Resources.Annotations;
@@ -69,25 +69,35 @@ private static Stack TokensToStack(Token[] tokens)
return stack;
}
+ ///
+ /// Indicates whether the specified token represents the 'null' constant keyword.
+ ///
+ protected static bool IsNullConstantToken([NotNullWhen(true)] Token? token)
+ {
+ return token is { Kind: TokenKind.Text, Value: Keywords.Null };
+ }
+
///
/// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern.
///
- protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType,
- string? alternativeErrorMessage)
+ protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, IAttributeContainer container,
+ FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None)
{
ArgumentNullException.ThrowIfNull(pattern);
- ArgumentNullException.ThrowIfNull(resourceType);
+ ArgumentNullException.ThrowIfNull(container);
int startPosition = GetNextTokenPositionOrEnd();
- string path = EatFieldChain(alternativeErrorMessage);
- PatternMatchResult result = pattern.Match(path, resourceType, options);
+ string path = EatFieldChain();
+ PatternMatchResult result = pattern.Match(path, container, options);
if (!result.IsSuccess)
{
+ string containerDescription = container is ResourceType resourceType ? $"resource type '{resourceType}'" : $"compound type '{container}'";
+
string message = result.IsFieldChainError
? result.FailureMessage
- : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}";
+ : $"Field chain on {containerDescription} failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}";
throw new QueryParseException(message, startPosition + result.FailurePosition);
}
@@ -104,7 +114,7 @@ protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern
return new ResourceFieldChainExpression(result.FieldChain.ToImmutableArray());
}
- private string EatFieldChain(string? alternativeErrorMessage)
+ private string EatFieldChain()
{
var pathBuilder = new StringBuilder();
@@ -112,13 +122,12 @@ private string EatFieldChain(string? alternativeErrorMessage)
{
int position = GetNextTokenPositionOrEnd();
- if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null)
+ if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && !IsNullConstantToken(token))
{
pathBuilder.Append(token.Value);
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period)
+ if (TryEatSingleCharacterToken(TokenKind.Period))
{
- EatSingleCharacterToken(TokenKind.Period);
pathBuilder.Append('.');
}
else
@@ -128,7 +137,7 @@ private string EatFieldChain(string? alternativeErrorMessage)
}
else
{
- throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position);
+ throw new QueryParseException(QueryParseChoices.FieldName, position);
}
}
}
@@ -137,7 +146,7 @@ private string EatFieldChain(string? alternativeErrorMessage)
/// Consumes a token containing the expected text from the top of . Throws a if a different
/// token kind is at the top, it contains a different text, or if there are no more tokens available.
///
- protected void EatText(string text)
+ protected virtual void EatText(string text)
{
ArgumentNullException.ThrowIfNull(text);
@@ -149,8 +158,24 @@ protected void EatText(string text)
}
///
- /// Consumes the expected token kind from the top of . Throws a if a different token kind is
- /// at the top, or if there are no more tokens available.
+ /// Consumes a token containing the expected text from the top of if present.
+ ///
+ protected virtual bool TryEatText(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == text)
+ {
+ TokenStack.Pop();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Consumes the expected single-character token kind from the top of . Throws a if a
+ /// different token kind is at the top, or if there are no more tokens available.
///
protected virtual void EatSingleCharacterToken(TokenKind kind)
{
@@ -166,6 +191,25 @@ protected virtual void EatSingleCharacterToken(TokenKind kind)
}
}
+ ///
+ /// Consumes the expected single-character token kind from the top of if present.
+ ///
+ protected virtual bool TryEatSingleCharacterToken(TokenKind kind)
+ {
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == kind)
+ {
+ if (!QueryTokenizer.TokenKindToSingleCharacterLookup.TryGetValue(kind, out _))
+ {
+ throw new InvalidOperationException($"Token kind '{kind}' is not a single-character token.");
+ }
+
+ TokenStack.Pop();
+ return true;
+ }
+
+ return false;
+ }
+
///
/// Gets the zero-based position of the token at the top of , or the position at the end of the source text if there are no more
/// tokens available.
@@ -206,7 +250,7 @@ protected void AssertTokenStackIsEmpty()
if (TokenStack.Count > 0)
{
int position = GetNextTokenPositionOrEnd();
- throw new QueryParseException("End of expression expected.", position);
+ throw new QueryParseException(QueryParseChoices.EndOfExpression, position);
}
}
@@ -231,7 +275,7 @@ protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(Reso
isAtStart = false;
}
- ParseRelationshipChain(treeRoot, false, null);
+ ParseRelationshipChain(treeRoot, false, true);
}
return treeRoot.ToExpression();
@@ -240,17 +284,17 @@ protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(Reso
///
/// Parses a relationship chain that ends in a to-many relationship, taking relationships on derived types into account.
///
- protected IncludeExpression ParseRelationshipChainEndingInToMany(ResourceType resourceType, string? alternativeErrorMessage)
+ protected IncludeExpression ParseRelationshipChainEndingInToMany(ResourceType resourceType)
{
ArgumentNullException.ThrowIfNull(resourceType);
var treeRoot = IncludeTreeNode.CreateRoot(resourceType);
- ParseRelationshipChain(treeRoot, true, alternativeErrorMessage);
+ ParseRelationshipChain(treeRoot, true, false);
return treeRoot.ToExpression();
}
- private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainEndsInToMany, string? alternativeErrorMessage)
+ private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainEndsInToMany, bool validateCanInclude)
{
// A relationship name usually matches a single relationship, even when overridden in derived types.
// But in the following case, two relationships are matched on GET /shoppingBaskets?include=items:
@@ -278,18 +322,16 @@ private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainE
// that there's currently no way to include Products without Articles. We could add such optional upcast syntax
// in the future, if desired.
- ReadOnlyCollection children = ParseRelationshipName([treeRoot], requireChainEndsInToMany, alternativeErrorMessage);
+ ReadOnlyCollection children = ParseRelationshipName([treeRoot], requireChainEndsInToMany, validateCanInclude);
- while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period)
+ while (TryEatSingleCharacterToken(TokenKind.Period))
{
- EatSingleCharacterToken(TokenKind.Period);
-
- children = ParseRelationshipName(children, requireChainEndsInToMany, null);
+ children = ParseRelationshipName(children, requireChainEndsInToMany, validateCanInclude);
}
}
private ReadOnlyCollection ParseRelationshipName(IReadOnlyCollection parents, bool requireChainEndsInToMany,
- string? alternativeErrorMessage)
+ bool validateCanInclude)
{
int position = GetNextTokenPositionOrEnd();
@@ -298,15 +340,15 @@ private ReadOnlyCollection ParseRelationshipName(IReadOnlyColle
bool isAtEndOfChain = !TokenStack.TryPeek(out Token? nextToken) || nextToken.Kind != TokenKind.Period;
bool requireToMany = requireChainEndsInToMany && isAtEndOfChain;
- return LookupRelationshipName(token.Value!, parents, requireToMany, position);
+ return LookupRelationshipName(token.Value!, parents, requireToMany, validateCanInclude, position);
}
- string message = alternativeErrorMessage ?? (requireChainEndsInToMany ? "To-many relationship name expected." : "Relationship name expected.");
- throw new QueryParseException(message, position);
+ QueryParseChoices choices = requireChainEndsInToMany ? QueryParseChoices.ToManyRelationshipName : QueryParseChoices.RelationshipName;
+ throw new QueryParseException(choices, position);
}
private ReadOnlyCollection LookupRelationshipName(string relationshipName, IReadOnlyCollection parents,
- bool requireToMany, int position)
+ bool requireToMany, bool validateCanInclude, int position)
{
List children = [];
HashSet relationshipsFound = [];
@@ -321,14 +363,21 @@ private ReadOnlyCollection LookupRelationshipName(string relati
{
relationshipsFound.UnionWith(relationships);
- RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray();
- ReadOnlyCollection affectedChildren = parent.EnsureChildren(relationshipsToInclude);
+ RelationshipAttribute[] relationshipsToAttach = validateCanInclude
+ ? relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray()
+ : relationships.ToArray();
+
+ ReadOnlyCollection affectedChildren = parent.EnsureChildren(relationshipsToAttach);
children.AddRange(affectedChildren);
}
}
AssertRelationshipsFound(relationshipsFound, relationshipName, requireToMany, parents, position);
- AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, position);
+
+ if (validateCanInclude)
+ {
+ AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, position);
+ }
return children.AsReadOnly();
}
@@ -396,22 +445,17 @@ private static string GetErrorMessageForNoneFound(string relationshipName, bool
builder.Append($" does not exist on any of the resource types {typeNames}");
}
- builder.Append(hasDerivedTypes ? " or any of its derived types." : ".");
+ builder.Append(hasDerivedTypes ? parentResourceTypes.Length == 1 ? " or any of its derived types." : " or any of their derived types." : ".");
return builder.ToString();
}
- private void AssertAtLeastOneCanBeIncluded(HashSet relationshipsFound, string relationshipName, int position)
+ private static void AssertAtLeastOneCanBeIncluded(HashSet relationshipsFound, string relationshipName, int position)
{
if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked()))
{
ResourceType resourceType = relationshipsFound.First().LeftType;
- string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed.";
-
- var exception = new QueryParseException(message, position);
- string specificMessage = exception.GetMessageWithPosition(Source);
-
- throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage);
+ throw new QueryParseException($"Including relationship '{relationshipName}' on '{resourceType}' is not allowed.", position);
}
}
@@ -452,7 +496,7 @@ public IncludeExpression ToExpression()
if (element.Relationship is HiddenRootRelationshipAttribute)
{
- return element.Children.Count > 0 ? new IncludeExpression(element.Children) : IncludeExpression.Empty;
+ return IncludeExpression.Create(element.Children);
}
return new IncludeExpression(ImmutableHashSet.Create(element));
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs
new file mode 100644
index 0000000000..57eedb3da9
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoiceFormatter.cs
@@ -0,0 +1,149 @@
+using System.Text;
+
+namespace JsonApiDotNetCore.Queries.Parsing;
+
+///
+/// Formats a set of and optional custom choices into a human-readable error message.
+///
+internal static class QueryParseChoiceFormatter
+{
+ public static string Format(QueryParseChoices choices, params ReadOnlySpan customChoices)
+ {
+ List choiceList = [];
+
+ if (choices.HasFlag(QueryParseChoices.Minus))
+ {
+ choiceList.Add(new ChoiceValue("-"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.OpenParenthesis))
+ {
+ choiceList.Add(new ChoiceValue("("));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.FilterFunction))
+ {
+ choiceList.Add(new ChoiceValue("filter function"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.CountFunction))
+ {
+ choiceList.Add(new ChoiceValue("count function", true));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.Function))
+ {
+ choiceList.Add(new ChoiceValue("function"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.Number))
+ {
+ choiceList.Add(new ChoiceValue("number"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.ParameterName))
+ {
+ choiceList.Add(new ChoiceValue("parameter name"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.ResourceType))
+ {
+ choiceList.Add(new ChoiceValue("resource type"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.FieldName))
+ {
+ choiceList.Add(new ChoiceValue("field name"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.RelationshipName))
+ {
+ choiceList.Add(new ChoiceValue("relationship name"));
+ }
+ else if (choices.HasFlag(QueryParseChoices.ToOneRelationshipName))
+ {
+ choiceList.Add(new ChoiceValue("to-one relationship name"));
+ }
+ else if (choices.HasFlag(QueryParseChoices.ToManyRelationshipName))
+ {
+ choiceList.Add(new ChoiceValue("to-many relationship name"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.QuotedText))
+ {
+ choiceList.Add(new ChoiceValue("value between quotes"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.Underscore))
+ {
+ choiceList.Add(new ChoiceValue("_"));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.Null))
+ {
+ choiceList.Add(new ChoiceValue(Keywords.Null, true));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.Comma))
+ {
+ choiceList.Add(new ChoiceValue(","));
+ }
+
+ if (choices.HasFlag(QueryParseChoices.EndOfExpression))
+ {
+ choiceList.Add(new ChoiceValue("end of expression"));
+ }
+
+ foreach (string customChoice in customChoices)
+ {
+ if (!string.IsNullOrWhiteSpace(customChoice))
+ {
+ choiceList.Add(new ChoiceValue(customChoice, true));
+ }
+ }
+
+ return FormatChoices(choiceList);
+ }
+
+ private static string FormatChoices(List choices)
+ {
+ var builder = new StringBuilder();
+
+ if (choices.Count > 0)
+ {
+ for (int index = 0; index < choices.Count; index++)
+ {
+ ChoiceValue choiceValue = choices[index];
+
+ if (index == 0)
+ {
+ if (choiceValue.PreserveCase)
+ {
+ builder.Append(choiceValue.Text);
+ }
+ else
+ {
+ builder.Append(char.ToUpperInvariant(choiceValue.Text[0]));
+ builder.Append(choiceValue.Text.AsSpan(1));
+ }
+ }
+ else if (index == choices.Count - 1)
+ {
+ builder.Append(" or ");
+ builder.Append(choiceValue.Text);
+ }
+ else
+ {
+ builder.Append(", ");
+ builder.Append(choiceValue.Text);
+ }
+ }
+
+ builder.Append(" expected.");
+ }
+
+ return builder.ToString();
+ }
+
+ private readonly record struct ChoiceValue(string Text, bool PreserveCase = false);
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs
new file mode 100644
index 0000000000..24f904bbd8
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseChoices.cs
@@ -0,0 +1,27 @@
+namespace JsonApiDotNetCore.Queries.Parsing;
+
+///
+/// Lists the grammatical choices that can be expected at a token position in a query string expression.
+///
+[Flags]
+public enum QueryParseChoices
+{
+ None = 0,
+ Minus = 1 << 0,
+ OpenParenthesis = 1 << 1,
+ FilterFunction = 1 << 2,
+ CountFunction = 1 << 3,
+ Function = 1 << 4,
+ Number = 1 << 5,
+ ParameterName = 1 << 6,
+ ResourceType = 1 << 7,
+ FieldName = 1 << 8,
+ ToOneRelationshipName = 1 << 9,
+ ToManyRelationshipName = 1 << 10,
+ RelationshipName = ToOneRelationshipName | ToManyRelationshipName,
+ QuotedText = 1 << 11,
+ Underscore = 1 << 12,
+ Null = 1 << 13,
+ Comma = 1 << 14,
+ EndOfExpression = 1 << 15
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs
index 4f63f45e24..3f841f7431 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs
@@ -20,6 +20,11 @@ public QueryParseException(string message, int position)
Position = position;
}
+ public QueryParseException(QueryParseChoices choices, int position, params ReadOnlySpan customChoices)
+ : this(QueryParseChoiceFormatter.Format(choices, customChoices), position)
+ {
+ }
+
public QueryParseException(string message, int position, Exception innerException)
: base(message, innerException)
{
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs
index 5ba95f3d98..e21e031c19 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs
@@ -30,18 +30,16 @@ protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterS
if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text)
{
- throw new QueryParseException("Parameter name expected.", position);
+ throw new QueryParseException(QueryParseChoices.ParameterName, position);
}
var name = new LiteralConstantExpression(token.Value!);
IncludeExpression? scope = null;
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket)
+ if (TryEatSingleCharacterToken(TokenKind.OpenBracket))
{
- TokenStack.Pop();
-
- scope = ParseRelationshipChainEndingInToMany(resourceType, null);
+ scope = ParseRelationshipChainEndingInToMany(resourceType);
EatSingleCharacterToken(TokenKind.CloseBracket);
}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs
index bf7b714e96..bdee55584c 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs
@@ -15,7 +15,8 @@ public sealed class QueryTokenizer
['.'] = TokenKind.Period,
[','] = TokenKind.Comma,
[':'] = TokenKind.Colon,
- ['-'] = TokenKind.Minus
+ ['-'] = TokenKind.Minus,
+ ['_'] = TokenKind.Underscore
}.AsReadOnly();
internal static readonly IReadOnlyDictionary TokenKindToSingleCharacterLookup =
@@ -69,7 +70,7 @@ public IEnumerable EnumerateTokens()
{
if (_textBuffer.Length > 0)
{
- throw new QueryParseException("Unexpected ' outside text.", _sourceOffset);
+ throw new QueryParseException("Unexpected ' outside quoted text.", _sourceOffset);
}
_isInQuotedSection = true;
@@ -79,7 +80,7 @@ public IEnumerable EnumerateTokens()
{
TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch);
- if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value))
+ if (singleCharacterTokenKind != null && !IsPunctuationInsideText(singleCharacterTokenKind.Value))
{
Token? identifierToken = ProduceTokenFromTextBuffer(false);
@@ -108,7 +109,7 @@ public IEnumerable EnumerateTokens()
if (_isInQuotedSection)
{
- throw new QueryParseException("' expected.", _sourceOffset - 1);
+ throw new QueryParseException("Closing ' expected.", _sourceOffset - 1);
}
Token? lastToken = ProduceTokenFromTextBuffer(false);
@@ -119,9 +120,9 @@ public IEnumerable EnumerateTokens()
}
}
- private bool IsMinusInsideText(TokenKind kind)
+ private bool IsPunctuationInsideText(TokenKind kind)
{
- return kind == TokenKind.Minus && _textBuffer.Length > 0;
+ return kind is TokenKind.Minus or TokenKind.Underscore && _textBuffer.Length > 0;
}
private char? PeekChar()
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs b/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs
new file mode 100644
index 0000000000..aa1f0e0dbc
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/Parsing/ScalarCollectionElementFilterScope.cs
@@ -0,0 +1,28 @@
+using JetBrains.Annotations;
+
+namespace JsonApiDotNetCore.Queries.Parsing;
+
+///
+/// Represents a filter scope on the element of a scalar collection attribute.
+///
+[PublicAPI]
+public sealed class ScalarCollectionElementFilterScope : FilterScope
+{
+ ///
+ /// Gets the CLR type of the scalar collection element.
+ ///
+ public Type ElementType { get; }
+
+ public ScalarCollectionElementFilterScope(Type elementType)
+ {
+ ArgumentNullException.ThrowIfNull(elementType);
+
+ ElementType = elementType;
+ }
+
+ ///
+ public override string ToString()
+ {
+ return ElementType.Name;
+ }
+}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs
index 814a3e4c9e..744141caf9 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs
@@ -49,12 +49,11 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy
{
ArgumentNullException.ThrowIfNull(resourceType);
- bool isAscending = true;
+ QueryParseChoices choices = QueryParseChoices.Minus | QueryParseChoices.CountFunction | QueryParseChoices.FieldName;
- if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus)
+ if (TryEatSingleCharacterToken(TokenKind.Minus))
{
- TokenStack.Pop();
- isAscending = false;
+ choices &= ~QueryParseChoices.Minus;
}
// An attribute or relationship name usually matches a single field, even when overridden in derived types.
@@ -82,19 +81,27 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy
// Because there is no syntax to pick one, ParseFieldChain() fails with an error. We could add optional upcast syntax
// (which would be required in this case) in the future to make it work, if desired.
- QueryExpression target;
-
- if (TokenStack.TryPeek(out nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!))
- {
- target = ParseFunction(resourceType);
- }
- else
+ if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text })
{
- string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected.";
- target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage);
+ bool isAscending = choices.HasFlag(QueryParseChoices.Minus);
+
+ if (IsFunction(nextToken.Value!))
+ {
+ QueryExpression functionTarget = ParseFunction(resourceType);
+ return new SortElementExpression(functionTarget, isAscending);
+ }
+
+ if (!IsNullConstantToken(nextToken))
+ {
+ QueryExpression fieldChainTarget = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInScalarAttribute, resourceType,
+ FieldChainPatternMatchOptions.AllowDerivedTypes);
+
+ return new SortElementExpression(fieldChainTarget, isAscending);
+ }
}
- return new SortElementExpression(target, isAscending);
+ int position = GetNextTokenPositionOrEnd();
+ throw new QueryParseException(choices, position);
}
protected virtual bool IsFunction(string name)
@@ -120,16 +127,16 @@ protected virtual FunctionExpression ParseFunction(ResourceType resourceType)
}
int position = GetNextTokenPositionOrEnd();
- throw new QueryParseException("Count function expected.", position);
+ throw new QueryParseException(QueryParseChoices.CountFunction, position);
}
- private CountExpression ParseCount(ResourceType resourceType)
+ protected virtual CountExpression ParseCount(ResourceType resourceType)
{
EatText(Keywords.Count);
EatSingleCharacterToken(TokenKind.OpenParen);
- ResourceFieldChainExpression targetCollection =
- ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null);
+ ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneOrCompoundChainEndingInCollection, resourceType,
+ FieldChainPatternMatchOptions.AllowDerivedTypes);
EatSingleCharacterToken(TokenKind.CloseParen);
@@ -140,7 +147,9 @@ protected override void ValidateField(ResourceFieldAttribute field, int position
{
ArgumentNullException.ThrowIfNull(field);
- if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort))
+ // Intermediate compound attributes are skipped during capability checks because AllowSort was stripped at startup
+ // to prevent sorting by whole compound objects, while built-in sort pattern '[OC]*S' ensures only leaf scalar attributes are evaluated.
+ if (field is AttrAttribute { IsCompound: false, IsCollection: false } attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort))
{
throw new QueryParseException($"Sorting on attribute '{attribute}' is not allowed.", position);
}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs
index fb49e9c1dd..bd535fbef7 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs
@@ -1,4 +1,3 @@
-using System.Collections.Immutable;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
@@ -12,40 +11,42 @@ namespace JsonApiDotNetCore.Queries.Parsing;
public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser
{
///
- public SparseFieldSetExpression? Parse(string source, ResourceType resourceType)
+ public SparseFieldSetExpression Parse(string source, ResourceType resourceType)
{
ArgumentNullException.ThrowIfNull(resourceType);
Tokenize(source);
- SparseFieldSetExpression? expression = ParseSparseFieldSet(resourceType);
+ SparseFieldSetExpression expression = ParseSparseFieldSet(resourceType);
AssertTokenStackIsEmpty();
return expression;
}
- protected virtual SparseFieldSetExpression? ParseSparseFieldSet(ResourceType resourceType)
+ protected virtual SparseFieldSetExpression ParseSparseFieldSet(ResourceType resourceType)
{
ArgumentNullException.ThrowIfNull(resourceType);
- ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder();
+ var treeBuilder = new SparseFieldTreeBuilder();
+ bool isFirstChain = true;
while (TokenStack.Count > 0)
{
- if (fieldSetBuilder.Count > 0)
+ if (isFirstChain)
+ {
+ isFirstChain = false;
+ }
+ else
{
EatSingleCharacterToken(TokenKind.Comma);
}
- ResourceFieldChainExpression nextChain =
- ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected.");
-
- ResourceFieldAttribute nextField = nextChain.Fields.Single();
- fieldSetBuilder.Add(nextField);
+ ResourceFieldChainExpression nextChain = ParseFieldChain(BuiltInPatterns.CompoundOrCompoundCollectionChainEndingInField, resourceType);
+ treeBuilder.IncludeChain(nextChain.Fields);
}
- return fieldSetBuilder.Count > 0 ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null;
+ return treeBuilder.ToExpression();
}
protected override void ValidateField(ResourceFieldAttribute field, int position)
@@ -55,7 +56,7 @@ protected override void ValidateField(ResourceFieldAttribute field, int position
if (field.IsViewBlocked())
{
string kind = field is AttrAttribute ? "attribute" : "relationship";
- throw new QueryParseException($"Retrieving the {kind} '{field}' is not allowed.", position);
+ throw new QueryParseException($"Retrieving {kind} '{field}' is not allowed.", position);
}
}
}
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs
index 00c5c6a525..a70ea4ffae 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs
@@ -34,7 +34,7 @@ protected virtual ResourceType ParseSparseFieldType()
if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text)
{
- throw new QueryParseException("Parameter name expected.", position);
+ throw new QueryParseException(QueryParseChoices.ParameterName, position);
}
EatSingleCharacterToken(TokenKind.OpenBracket);
@@ -55,7 +55,7 @@ private ResourceType ParseResourceType()
return GetResourceType(token.Value!, token.Position);
}
- throw new QueryParseException("Resource type expected.", position);
+ throw new QueryParseException(QueryParseChoices.ResourceType, position);
}
private ResourceType GetResourceType(string publicName, int position)
diff --git a/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs
index 23fd428bf5..60309e3eff 100644
--- a/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs
+++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs
@@ -10,6 +10,7 @@ public enum TokenKind
Comma,
Colon,
Minus,
+ Underscore,
Text,
QuotedText
}
diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs
index adc4ad2b09..e98132d1a0 100644
--- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs
@@ -2,6 +2,7 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
+using JsonApiDotNetCore.Resources.Annotations;
namespace JsonApiDotNetCore.Queries;
@@ -15,10 +16,89 @@ public sealed class QueryLayer
public ResourceType ResourceType { get; }
+ ///
+ /// Gets or sets the relationships to include. This only takes affect at the topmost layer.
+ ///
+ /// The distinction between null and is significant:
+ ///
+ /// -
+ ///
+ /// null
+ ///
+ ///
+ /// Omits all includes, bypassing eager loading completely (no annotations are loaded). Used for internal count
+ /// queries, ID-existence checks, and relationship endpoints where full entity graphs are not needed.
+ ///
+ ///
+ /// -
+ ///
+ ///
+ ///
+ ///
+ /// Indicates not to include any related resources, except for annotations on the root resource type, which will still
+ /// be loaded. Used for standard queries when no
+ ///
+ /// ?include=
+ ///
+ /// parameter is specified.
+ ///
+ ///
+ /// -
+ ///
+ /// Non-empty
+ ///
+ ///
+ /// Includes the specified relationship paths, as well as any annotations on the root resource and along the included
+ /// relationship chains.
+ ///
+ ///
+ ///
+ ///
+ ///
public IncludeExpression? Include { get; set; }
+
public FilterExpression? Filter { get; set; }
public SortExpression? Sort { get; set; }
public PaginationExpression? Pagination { get; set; }
+
+ ///
+ /// Gets or sets the field projection (sparse fieldsets and relationship sub-layers) to apply.
+ ///
+ /// The distinction between null and an empty (where is true) is:
+ ///
+ /// -
+ ///
+ /// null
+ ///
+ ///
+ /// Bypasses database-level field projection entirely; no
+ ///
+ /// .Select(...)
+ ///
+ /// clause is applied to the query. The data store materializes the entire entity with all mapped properties. Used when no sparse fieldset is requested
+ /// or when the full database row is required (e.g. ).
+ ///
+ ///
+ /// -
+ ///
+ /// Empty
+ ///
+ ///
+ /// Applies a database projection that selects all scalar properties on the entity type (along with any annotations),
+ /// without restricting attributes or embedding nested relationship sub-layers.
+ ///
+ ///
+ /// -
+ ///
+ /// Non-empty
+ ///
+ ///
+ /// Restricts database projection to only the selected attributes and/or relationships. Unselected attributes are omitted from the database query result.
+ ///
+ ///
+ ///
+ ///
+ ///
public FieldSelection? Selection { get; set; }
public QueryLayer(ResourceType resourceType)
diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs
index f058862bcd..c4f95d0b4a 100644
--- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs
@@ -20,11 +20,11 @@ public class QueryLayerComposer : IQueryLayerComposer
private readonly IPaginationContext _paginationContext;
private readonly ITargetedFields _targetedFields;
private readonly IEvaluatedIncludeCache _evaluatedIncludeCache;
- private readonly ISparseFieldSetCache _sparseFieldSetCache;
+ private readonly ISparseFieldSetEvaluator _sparseFieldSetEvaluator;
public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor,
IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache,
- ISparseFieldSetCache sparseFieldSetCache)
+ ISparseFieldSetEvaluator sparseFieldSetEvaluator)
{
ArgumentNullException.ThrowIfNull(constraintProviders);
ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor);
@@ -32,7 +32,7 @@ public QueryLayerComposer(IEnumerable constraintProvid
ArgumentNullException.ThrowIfNull(paginationContext);
ArgumentNullException.ThrowIfNull(targetedFields);
ArgumentNullException.ThrowIfNull(evaluatedIncludeCache);
- ArgumentNullException.ThrowIfNull(sparseFieldSetCache);
+ ArgumentNullException.ThrowIfNull(sparseFieldSetEvaluator);
_constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray();
_resourceDefinitionAccessor = resourceDefinitionAccessor;
@@ -40,30 +40,7 @@ public QueryLayerComposer(IEnumerable constraintProvid
_paginationContext = paginationContext;
_targetedFields = targetedFields;
_evaluatedIncludeCache = evaluatedIncludeCache;
- _sparseFieldSetCache = sparseFieldSetCache;
- }
-
- ///
- [Obsolete("This method is no longer used and will be removed in a future version.")]
- public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType)
- {
- ArgumentNullException.ThrowIfNull(primaryResourceType);
-
- // @formatter:wrap_chained_method_calls chop_always
- // @formatter:wrap_before_first_method_call true
-
- ReadOnlyCollection filtersInTopScope = _constraintProviders
- .SelectMany(provider => provider.GetConstraints())
- .Where(constraint => constraint.Scope == null)
- .Select(constraint => constraint.Expression)
- .OfType()
- .ToArray()
- .AsReadOnly();
-
- // @formatter:wrap_before_first_method_call restore
- // @formatter:wrap_chained_method_calls restore
-
- return GetFilter(filtersInTopScope, primaryResourceType);
+ _sparseFieldSetEvaluator = sparseFieldSetEvaluator;
}
///
@@ -85,21 +62,10 @@ public QueryLayerComposer(IEnumerable constraintProvid
return null;
}
- // @formatter:wrap_chained_method_calls chop_always
- // @formatter:wrap_before_first_method_call true
-
- ReadOnlyCollection filtersInSecondaryScope = _constraintProviders
- .SelectMany(provider => provider.GetConstraints())
- .Where(constraint => constraint.Scope == null)
- .Select(constraint => constraint.Expression)
- .OfType()
- .ToArray()
- .AsReadOnly();
-
- // @formatter:wrap_before_first_method_call restore
- // @formatter:wrap_chained_method_calls restore
+ ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())];
+ ReadOnlyCollection expressionsInSecondaryScope = GetExpressionsInTopScope(constraints);
- FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType);
+ FilterExpression? primaryFilter = GetFilterFromResourceDefinition(hasManyRelationship.LeftType);
if (primaryFilter != null && inverseRelationship is HasOneAttribute)
{
@@ -108,7 +74,7 @@ public QueryLayerComposer(IEnumerable constraintProvid
return null;
}
- FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType);
+ FilterExpression? secondaryFilter = GetFilter(expressionsInSecondaryScope, hasManyRelationship.RightType);
FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, primaryFilter, hasManyRelationship, inverseRelationship);
return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, secondaryFilter);
@@ -134,11 +100,7 @@ private static ComparisonExpression GetInverseHasOneRelationshipFilter([Dis
private static HasExpression GetInverseHasManyRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter,
HasManyAttribute relationship, HasManyAttribute inverseRelationship)
{
- AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType);
- var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute));
- var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId));
-
- FilterExpression filter = LogicalExpression.Compose(LogicalOperator.And, idComparison, primaryFilter)!;
+ FilterExpression filter = CreateFilterForIds(relationship.LeftType, primaryFilter, primaryId)!;
return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), filter);
}
@@ -152,8 +114,6 @@ public QueryLayer ComposeFromConstraints(ResourceType requestResourceType)
QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType);
topLayer.Include = ComposeChildren(topLayer, constraints);
- _evaluatedIncludeCache.Set(topLayer.Include);
-
return topLayer;
}
@@ -161,36 +121,54 @@ private QueryLayer ComposeTopLayer(ImmutableArray constraints
{
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition");
+ ReadOnlyCollection expressionsInTopScope = GetExpressionsInTopScope(constraints);
+
+ PaginationExpression topPagination = GetTopPagination(expressionsInTopScope, resourceType);
+
+ var topLayer = new QueryLayer(resourceType)
+ {
+ Filter = GetFilter(expressionsInTopScope, resourceType),
+ Sort = GetSort(expressionsInTopScope, resourceType),
+ Pagination = topPagination,
+ Selection = GetSelectionForSparseAttributeSet(resourceType)
+ };
+
+ EnsureDeterministicSort(topLayer);
+
+ return topLayer;
+ }
+
+ private static ReadOnlyCollection GetExpressionsInTopScope(ImmutableArray constraints)
+ {
// @formatter:wrap_chained_method_calls chop_always
// @formatter:wrap_before_first_method_call true
- ReadOnlyCollection expressionsInTopScope = constraints
- .Where(constraint => constraint.Scope == null)
+ return constraints
+ .Where(constraint => constraint is not RelationshipChainExpressionInScope chainScope || chainScope.Scope == null)
.Select(constraint => constraint.Expression)
.ToArray()
.AsReadOnly();
// @formatter:wrap_before_first_method_call restore
// @formatter:wrap_chained_method_calls restore
+ }
+ private PaginationExpression GetTopPagination(IReadOnlyCollection expressionsInTopScope, ResourceType resourceType)
+ {
PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType);
+
_paginationContext.PageSize = topPagination.PageSize;
_paginationContext.PageNumber = topPagination.PageNumber;
- var topLayer = new QueryLayer(resourceType)
- {
- Filter = GetFilter(expressionsInTopScope, resourceType),
- Sort = GetSort(expressionsInTopScope, resourceType),
- Pagination = topPagination,
- Selection = GetSelectionForSparseAttributeSet(resourceType)
- };
+ return topPagination;
+ }
- if (topLayer is { Pagination.PageSize: not null, Sort: null })
+ private void EnsureDeterministicSort(QueryLayer layer)
+ {
+ if (layer is { Pagination.PageSize: not null, Sort: null })
{
- topLayer.Sort = CreateSortById(resourceType);
+ layer.Sort = CreateSortById(layer.ResourceType);
}
-
- return topLayer;
}
private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray constraints)
@@ -201,6 +179,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray()
.Where(constraint => constraint.Scope == null)
.Select(constraint => constraint.Expression)
.OfType()
@@ -212,9 +191,11 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray includeElements =
ProcessIncludeSet(include.Elements, topLayer, ImmutableArray.Empty, constraints);
- return !ReferenceEquals(includeElements, include.Elements)
- ? includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty
- : include;
+ IncludeExpression result = !ReferenceEquals(includeElements, include.Elements) ? IncludeExpression.Create(includeElements) : include;
+
+ _evaluatedIncludeCache.Set(result);
+
+ return result;
}
private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer,
@@ -237,6 +218,7 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet<
// @formatter:wrap_before_first_method_call true
ReadOnlyCollection expressionsInCurrentScope = constraints
+ .OfType()
.Where(constraint => constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain))
.Select(constraint => constraint.Expression)
.ToArray()
@@ -318,25 +300,26 @@ public QueryLayer ComposeForGetById([DisallowNull] TId id, ResourceType pri
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(primaryResourceType);
- AttrAttribute idAttribute = GetIdAttribute(primaryResourceType);
-
- QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType);
- queryLayer.Sort = null;
- queryLayer.Pagination = null;
- queryLayer.Filter = CreateFilterByIds([id], idAttribute, queryLayer.Filter);
+ FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType);
if (fieldSelection == TopFieldSelection.OnlyIdAttribute)
{
- queryLayer.Selection = new FieldSelection();
- FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType);
- selectors.IncludeAttribute(idAttribute);
+ return new QueryLayer(primaryResourceType)
+ {
+ Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id),
+ Selection = CreateSelectionForOnlyId(primaryResourceType)
+ };
}
- else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null)
+
+ ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())];
+
+ var queryLayer = new QueryLayer(primaryResourceType)
{
- // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row.
- FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType);
- selectors.RemoveAttributes();
- }
+ Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id),
+ Selection = fieldSelection == TopFieldSelection.WithAllAttributes ? null : GetSelectionForSparseAttributeSet(primaryResourceType)
+ };
+
+ queryLayer.Include = ComposeChildren(queryLayer, constraints);
return queryLayer;
}
@@ -346,20 +329,29 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryRes
{
ArgumentNullException.ThrowIfNull(secondaryResourceType);
- QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType);
- secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType);
- secondaryLayer.Include = null;
+ ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())];
+ ReadOnlyCollection expressionsInTopScope = GetExpressionsInTopScope(constraints);
+
+ var secondaryLayer = new QueryLayer(secondaryResourceType)
+ {
+ Filter = GetFilter(expressionsInTopScope, secondaryResourceType),
+ Sort = GetSort(expressionsInTopScope, secondaryResourceType),
+ Pagination = GetTopPagination(expressionsInTopScope, secondaryResourceType),
+ Selection = CreateSelectionForOnlyId(secondaryResourceType)
+ };
+
+ EnsureDeterministicSort(secondaryLayer);
return secondaryLayer;
}
- private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType)
+ private static FieldSelection CreateSelectionForOnlyId(ResourceType resourceType)
{
var selection = new FieldSelection();
- FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType);
+ FieldSelectors selectors = selection.GetOrCreateSelectors(resourceType);
- IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType);
- selectors.IncludeAttributes(secondaryAttributeSet);
+ AttrAttribute idAttribute = GetIdAttribute(resourceType);
+ selectors.IncludeAttribute(idAttribute);
return selection;
}
@@ -381,21 +373,17 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer,
secondaryLayer.Sort = null;
}
- var primarySelection = new FieldSelection();
+ FieldSelection primarySelection = CreateSelectionForOnlyId(primaryResourceType);
FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType);
-
- IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType);
- primarySelectors.IncludeAttributes(primaryAttributeSet);
primarySelectors.IncludeRelationship(relationship, secondaryLayer);
- FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType);
- AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
+ FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType);
var primaryLayer = new QueryLayer(primaryResourceType)
{
- Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship),
- Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter),
- Selection = primarySelection
+ Filter = CreateFilterForIds(primaryResourceType, primaryFilter, primaryId),
+ Selection = primarySelection,
+ Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship)
};
if (relationship is HasManyAttribute { DisablePagination: true } && secondaryLayer.Pagination != null)
@@ -413,7 +401,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer,
return primaryLayer;
}
- private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship)
+ private static IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship)
{
IncludeElementExpression parentElement = relativeInclude != null
? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements)
@@ -422,24 +410,36 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression?
return new IncludeExpression(ImmutableHashSet.Create(parentElement));
}
- private FilterExpression? CreateFilterByIds(TId[] ids, AttrAttribute idAttribute, FilterExpression? existingFilter)
+ private static FilterExpression? CreateFilterForIds(ResourceType resourceType, FilterExpression? existingFilter, params ReadOnlySpan ids)
{
+ if (ids.Length == 0)
+ {
+ return existingFilter;
+ }
+
+ AttrAttribute idAttribute = GetIdAttribute(resourceType);
var idChain = new ResourceFieldChainExpression(idAttribute);
- FilterExpression? filter = null;
+ FilterExpression innerFilter;
if (ids.Length == 1)
{
- var constant = new LiteralConstantExpression(ids.Single()!);
- filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant);
+ var constant = new LiteralConstantExpression(ids[0]!);
+ innerFilter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant);
}
- else if (ids.Length > 1)
+ else
{
- ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet();
- filter = new AnyExpression(idChain, constants);
+ ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder();
+
+ foreach (TId id in ids)
+ {
+ constantsBuilder.Add(new LiteralConstantExpression(id!));
+ }
+
+ innerFilter = new AnyExpression(idChain, constantsBuilder.ToImmutable());
}
- return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter);
+ return LogicalExpression.Compose(LogicalOperator.And, innerFilter, existingFilter);
}
///
@@ -451,16 +451,13 @@ public QueryLayer ComposeForUpdate([DisallowNull] TId id, ResourceType prim
ImmutableHashSet includeElements = _targetedFields.Relationships
.Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet();
- AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
+ FilterExpression? primaryFilter = GetFilterFromResourceDefinition(primaryResourceType);
- QueryLayer primaryLayer = ComposeTopLayer(ImmutableArray.Empty, primaryResourceType);
- primaryLayer.Include = includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty;
- primaryLayer.Sort = null;
- primaryLayer.Pagination = null;
- primaryLayer.Filter = CreateFilterByIds([id], primaryIdAttribute, primaryLayer.Filter);
- primaryLayer.Selection = null;
-
- return primaryLayer;
+ return new QueryLayer(primaryResourceType)
+ {
+ Filter = CreateFilterForIds(primaryResourceType, primaryFilter, id),
+ Include = IncludeExpression.Create(includeElements)
+ };
}
///
@@ -487,22 +484,15 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati
ArgumentNullException.ThrowIfNull(relationship);
ArgumentNullException.ThrowIfNull(rightResourceIds);
- AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType);
-
object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray();
- FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType);
- FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter);
-
- var selection = new FieldSelection();
- FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType);
- selectors.IncludeAttribute(rightIdAttribute);
+ FilterExpression? baseFilter = GetFilterFromResourceDefinition(relationship.RightType);
+ FilterExpression? filter = CreateFilterForIds(relationship.RightType, baseFilter, typedIds);
return new QueryLayer(relationship.RightType)
{
- Include = IncludeExpression.Empty,
Filter = filter,
- Selection = selection
+ Selection = CreateSelectionForOnlyId(relationship.RightType)
};
}
@@ -513,33 +503,26 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, [
ArgumentNullException.ThrowIfNull(leftId);
ArgumentNullException.ThrowIfNull(rightResourceIds);
- AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType);
- AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType);
object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray();
- FilterExpression? leftFilter = CreateFilterByIds([leftId], leftIdAttribute, null);
- FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null);
-
- var secondarySelection = new FieldSelection();
- FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType);
- secondarySelectors.IncludeAttribute(rightIdAttribute);
+ FilterExpression? leftFilter = CreateFilterForIds(hasManyRelationship.LeftType, null, leftId);
+ FilterExpression? rightFilter = CreateFilterForIds(hasManyRelationship.RightType, null, rightTypedIds);
QueryLayer secondaryLayer = new(hasManyRelationship.RightType)
{
Filter = rightFilter,
- Selection = secondarySelection
+ Selection = CreateSelectionForOnlyId(hasManyRelationship.RightType)
};
- var primarySelection = new FieldSelection();
+ FieldSelection primarySelection = CreateSelectionForOnlyId(hasManyRelationship.LeftType);
FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType);
primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer);
- primarySelectors.IncludeAttribute(leftIdAttribute);
return new QueryLayer(hasManyRelationship.LeftType)
{
- Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))),
Filter = leftFilter,
- Selection = primarySelection
+ Selection = primarySelection,
+ Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship)))
};
}
@@ -552,6 +535,11 @@ protected virtual IImmutableSet GetIncludeElements(IIm
return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements);
}
+ private FilterExpression? GetFilterFromResourceDefinition(ResourceType resourceType)
+ {
+ return GetFilter(Array.Empty(), resourceType);
+ }
+
protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType)
{
ArgumentNullException.ThrowIfNull(expressionsInScope);
@@ -605,23 +593,36 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType);
+ SparseFieldSetExpression? fieldSetExpression = _sparseFieldSetEvaluator.GetSparseFieldSetForQuery(nextType);
- if (fieldSet.Count == 0)
+ if (fieldSetExpression != null)
{
- continue;
+ FieldSelectors selectors = selection.GetOrCreateSelectors(nextType);
+ PopulateSelectors(selectors, fieldSetExpression.Elements);
+
+ AttrAttribute idAttribute = GetIdAttribute(nextType);
+ selectors.IncludeAttribute(idAttribute);
}
+ }
- HashSet attributeSet = fieldSet.OfType().ToHashSet();
+ return selection.IsEmpty ? null : selection;
+ }
- FieldSelectors selectors = selection.GetOrCreateSelectors(nextType);
- selectors.IncludeAttributes(attributeSet);
+ private static void PopulateSelectors(FieldSelectors selectors, IImmutableDictionary elements)
+ {
+ foreach ((ResourceFieldAttribute field, SparseFieldSetElementExpression element) in elements)
+ {
+ if (field is AttrAttribute attr)
+ {
+ selectors.IncludeAttribute(attr);
- AttrAttribute idAttribute = GetIdAttribute(nextType);
- selectors.IncludeAttribute(idAttribute);
+ if (element.Children.Count > 0)
+ {
+ FieldSelectors childSelectors = selectors.GetOrCreateChildSelectors(attr);
+ PopulateSelectors(childSelectors, element.Children);
+ }
+ }
}
-
- return selection.IsEmpty ? null : selection;
}
private static AttrAttribute GetIdAttribute(ResourceType resourceType)
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs
index e46f1249ab..130c56fa14 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs
@@ -22,7 +22,7 @@ public override Expression VisitInclude(IncludeExpression expression, QueryClaus
// De-duplicate chains coming from derived relationships.
HashSet propertyPaths = [];
- ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths);
+ ApplyEagerLoads(context.ResourceType!.EagerLoads, null, propertyPaths);
foreach (ResourceFieldChainExpression chain in IncludeChainConverter.Instance.GetRelationshipChains(expression))
{
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
index c3b58ee125..9c176e77e8 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
@@ -17,9 +17,9 @@ public sealed class QueryClauseBuilderContext
public Expression Source { get; }
///
- /// The resource type for .
+ /// The resource type for , or null if not scoped to a resource type (e.g. for primitive or compound collections).
///
- public ResourceType ResourceType { get; }
+ public ResourceType? ResourceType { get; }
///
/// The extension type to generate calls on, typically or .
@@ -51,11 +51,10 @@ public sealed class QueryClauseBuilderContext
///
public object? State { get; }
- public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IReadOnlyModel entityModel,
+ public QueryClauseBuilderContext(Expression source, ResourceType? resourceType, Type extensionType, IReadOnlyModel entityModel,
LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state)
{
ArgumentNullException.ThrowIfNull(source);
- ArgumentNullException.ThrowIfNull(resourceType);
ArgumentNullException.ThrowIfNull(extensionType);
ArgumentNullException.ThrowIfNull(entityModel);
ArgumentNullException.ThrowIfNull(lambdaScopeFactory);
@@ -73,8 +72,13 @@ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, T
State = state;
}
- private static void AssertSameType(Type sourceType, ResourceType resourceType)
+ private static void AssertSameType(Type sourceType, ResourceType? resourceType)
{
+ if (resourceType == null)
+ {
+ return;
+ }
+
Type? sourceElementType = CollectionConverter.Instance.FindCollectionElementType(sourceType);
if (sourceElementType != resourceType.ClrType)
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs
index 1a1af59749..c0af1f7ddb 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs
@@ -135,9 +135,24 @@ protected virtual Expression ApplySelection(Expression source, FieldSelection se
ArgumentNullException.ThrowIfNull(resourceType);
ArgumentNullException.ThrowIfNull(context);
+ if (HasUnsupportedComplexTypeProjection(selection, resourceType))
+ {
+ // Workaround for EF Core 10 bug (dotnet/efcore#39322):
+ // RelationalShapedQueryCompilingExpressionVisitor fails with an InvalidCastException
+ // when compiling a MemberInit projection on an entity that contains a ComplexProperty.
+ // Bypassing database-level projection allows EF Core to materialize the entity normally;
+ // ResponseModelAdapter performs the necessary sparse fieldset pruning in memory.
+ return source;
+ }
+
using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType);
QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope);
return _selectClauseBuilder.ApplySelect(selection, clauseContext);
}
+
+ private static bool HasUnsupportedComplexTypeProjection(FieldSelection selection, ResourceType resourceType)
+ {
+ return resourceType.Attributes.Any(attr => attr.IsCompound) || selection.GetResourceTypes().Any(type => type.Attributes.Any(attr => attr.IsCompound));
+ }
}
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
index 1be86e0bf5..1ba0b86933 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
@@ -29,7 +29,7 @@ public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuild
{
ArgumentNullException.ThrowIfNull(selection);
- Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, false, context);
+ Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType!, false, context);
LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter);
@@ -154,6 +154,12 @@ private static void IncludeAllScalarProperties(Type elementType, Dictionary navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty()))
{
@@ -166,7 +172,8 @@ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary();
+
+ if (childSelectors.ContainsReadOnlyAttribute)
+ {
+ // If a read-only attribute is selected within this compound type, its calculated value likely depends on another property,
+ // so fetch all writable properties of this compound type.
+ foreach (PropertyInfo property in compoundClrType.GetProperties().Where(p => p.SetMethod != null))
+ {
+ MemberExpression childPropertyAccess = Expression.Property(propertyAccess, property);
+ AttrAttribute? childAttr = childSelectors.Keys.OfType().FirstOrDefault(a => a.Property == property);
+ FieldSelectors? grandchildSelectors = childAttr != null ? childSelectors.GetChildSelectors(childAttr) : null;
+
+ Expression childRightHandSide;
+
+ if (grandchildSelectors is { IsEmpty: false } && childAttr is not { IsCollection: true })
+ {
+ childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context);
+ }
+ else
+ {
+ childRightHandSide = childPropertyAccess;
+ }
+
+ assignments.Add(Expression.Bind(property, childRightHandSide));
+ }
+ }
+ else
+ {
+ foreach ((ResourceFieldAttribute resourceField, _) in childSelectors)
+ {
+ if (resourceField is AttrAttribute childAttr && childAttr.Property.SetMethod != null)
+ {
+ MemberExpression childPropertyAccess = Expression.Property(propertyAccess, childAttr.Property);
+ FieldSelectors? grandchildSelectors = childSelectors.GetChildSelectors(childAttr);
+
+ Expression childRightHandSide;
+
+ if (grandchildSelectors is { IsEmpty: false } && !childAttr.IsCollection)
+ {
+ childRightHandSide = CreateCompoundMemberInit(grandchildSelectors, childPropertyAccess, context);
+ }
+ else
+ {
+ childRightHandSide = childPropertyAccess;
+ }
+
+ assignments.Add(Expression.Bind(childAttr.Property, childRightHandSide));
+ }
+ }
+ }
- return Expression.Bind(propertySelector.Property, assignmentRightHandSide);
+ NewExpression createInstance = _resourceFactory.CreateNewExpression(compoundClrType);
+ MemberInitExpression memberInit = Expression.MemberInit(createInstance, assignments);
+ return TestForNull(propertyAccess, memberInit);
}
private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, MemberExpression propertyAccess, PropertyInfo selectorPropertyInfo,
@@ -227,7 +303,10 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Membe
return CreateCollectionInitializer(selectorPropertyInfo, bodyElementType, layer, context);
}
- if (layer.Selection == null || layer.Selection.IsEmpty)
+ // Direct projection of entities containing compound attributes or collections is bypassed by EF Core queryable building
+ // due to an EF Core 10 complex type projection limitation (see https://github.com/dotnet/efcore/issues/39322).
+ if (layer.Selection == null || layer.Selection.IsEmpty || layer.ResourceType.Attributes.Any(attr => attr.IsCompound) ||
+ layer.Selection.GetResourceTypes().Any(type => type.Attributes.Any(attr => attr.IsCompound)))
{
return propertyAccess;
}
@@ -277,13 +356,15 @@ private sealed class PropertySelector
{
public PropertyInfo Property { get; }
public QueryLayer? NextLayer { get; }
+ public FieldSelectors? ChildSelectors { get; }
- public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null)
+ public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null, FieldSelectors? childSelectors = null)
{
ArgumentNullException.ThrowIfNull(property);
Property = property;
NextLayer = nextLayer;
+ ChildSelectors = childSelectors;
}
public override string ToString()
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs
index 0000456cc5..c82b4fedbc 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs
@@ -50,11 +50,11 @@ public override Expression VisitHas(HasExpression expression, QueryClauseBuilder
if (expression.Filter != null)
{
- ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType;
+ ResourceType? nestedResourceType = expression.TargetCollection.Fields[^1] is HasManyAttribute hasMany ? hasMany.RightType : null;
using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType);
- var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory,
+ var nestedContext = new QueryClauseBuilderContext(property, nestedResourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory,
lambdaScope, context.QueryableBuilder, context.State);
predicate = GetPredicateLambda(expression.Filter, nestedContext);
@@ -216,6 +216,11 @@ private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderCont
return typeof(int);
}
+ if (expression is ScalarCollectionElementValueExpression elementValue)
+ {
+ return elementValue.ElementType;
+ }
+
if (expression is ResourceFieldChainExpression chain)
{
Expression child = Visit(chain, context);
@@ -246,4 +251,9 @@ public override Expression VisitLiteralConstant(LiteralConstantExpression expres
{
return SystemExpressionBuilder.CloseOver(expression.TypedValue);
}
+
+ public override Expression VisitScalarCollectionElementValue(ScalarCollectionElementValueExpression expression, QueryClauseBuilderContext context)
+ {
+ return context.LambdaScope.Accessor;
+ }
}
diff --git a/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs
new file mode 100644
index 0000000000..c215f5cb3b
--- /dev/null
+++ b/src/JsonApiDotNetCore/Queries/RelationshipChainExpressionInScope.cs
@@ -0,0 +1,20 @@
+using JetBrains.Annotations;
+using JsonApiDotNetCore.Queries.Expressions;
+
+namespace JsonApiDotNetCore.Queries;
+
+///
+/// The expression that represents a parsed