diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index d77e271e4d..e713343770 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -21,13 +21,13 @@
]
},
"dotnet-reportgenerator-globaltool": {
- "version": "5.1.15",
+ "version": "5.1.19",
"commands": [
"reportgenerator"
]
},
"docfx": {
- "version": "2.60.2",
+ "version": "2.62.2",
"commands": [
"docfx"
]
diff --git a/Directory.Build.props b/Directory.Build.props
index 34807583da..a7ee36a2d9 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,7 +4,7 @@
6.0.*
7.0.*
7.0.*
- 4.4.*
+ 4.5.*
2.14.1
5.1.3
$(MSBuildThisFileDirectory)CodingGuidelines.ruleset
@@ -17,7 +17,7 @@
-
+
diff --git a/appveyor.yml b/appveyor.yml
index 3328af0009..befc9d9154 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,6 +1,8 @@
image:
- Ubuntu2004
- - Visual Studio 2022
+ # Downgrade to workaround error NETSDK1194 during 'dotnet pack': The "--output" option isn't supported when building a solution.
+ # https://stackoverflow.com/questions/75453953/how-to-fix-github-actions-dotnet-publish-workflow-error-the-output-option-i
+ - Previous Visual Studio 2022
version: '{build}'
@@ -32,7 +34,7 @@ for:
-
matrix:
only:
- - image: Visual Studio 2022
+ - image: Previous Visual Studio 2022
services:
- postgresql15
install:
diff --git a/docs/usage/options.md b/docs/usage/options.md
index 3512a9d9f2..6c896b9698 100644
--- a/docs/usage/options.md
+++ b/docs/usage/options.md
@@ -22,7 +22,7 @@ options.AllowClientGeneratedIds = true;
## Pagination
-The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to `null`.
+The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`.
The maximum page size and number allowed from client requests can be set too (unconstrained by default).
You can also include the total number of resources in each response.
@@ -38,7 +38,7 @@ options.IncludeTotalResourceCount = true;
```
To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined.
-If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full.
+If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full.
## Relative Links
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs
index 61c7e9d927..7e996828b9 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs
@@ -5,8 +5,8 @@ public enum LinkTypes
{
Self = 1 << 0,
Related = 1 << 1,
- Paging = 1 << 2,
+ Pagination = 1 << 2,
NotConfigured = 1 << 3,
None = 1 << 4,
- All = Self | Related | Paging
+ All = Self | Related | Pagination
}
diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
index bc7d17d89f..fcec2af464 100644
--- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
+++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
@@ -99,7 +99,7 @@ public interface IJsonApiOptions
bool IncludeTotalResourceCount { get; }
///
- /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default.
+ /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default.
///
PageSize? DefaultPageSize { get; }
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs
index e73b48ee3d..0e9f5d753f 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs
@@ -12,10 +12,7 @@ internal sealed class ResourceDescriptorAssemblyCache
public void RegisterAssembly(Assembly assembly)
{
- if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly))
- {
- _resourceDescriptorsPerAssembly[assembly] = null;
- }
+ _resourceDescriptorsPerAssembly.TryAdd(assembly, null);
}
public IReadOnlyCollection GetResourceDescriptors()
diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
index 22efab2840..fb3cd2bd2d 100644
--- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
+++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs
@@ -207,7 +207,7 @@ public virtual async Task PostAsync([FromBody] TResource resource
TResource? newResource = await _create.CreateAsync(resource, cancellationToken);
string resourceId = (newResource ?? resource).StringId!;
- string locationUrl = $"{HttpContext.Request.Path}/{resourceId}";
+ string locationUrl = HttpContext.Request.Path.Add($"/{resourceId}");
if (newResource == null)
{
diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs
index b73b256d25..97377b0d7b 100644
--- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs
+++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs
@@ -10,12 +10,11 @@ namespace JsonApiDotNetCore.Errors;
[PublicAPI]
public sealed class CannotClearRequiredRelationshipException : JsonApiException
{
- public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType)
+ public CannotClearRequiredRelationshipException(string relationshipName, string resourceType)
: base(new ErrorObject(HttpStatusCode.BadRequest)
{
Title = "Failed to clear a required relationship.",
- Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " +
- $"with ID '{resourceId}' cannot be cleared because it is a required relationship."
+ Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' cannot be cleared because it is a required relationship."
})
{
}
diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
index 5c6b84cba7..a6ef712adf 100644
--- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
+++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
@@ -101,10 +101,10 @@ public void Apply(ApplicationModel application)
$"resource type '{resourceClrType}', which does not exist in the resource graph.");
}
- if (_controllerPerResourceTypeMap.ContainsKey(resourceType))
+ if (_controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? existingModel))
{
throw new InvalidConfigurationException(
- $"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'.");
+ $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'.");
}
_resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
@@ -119,10 +119,10 @@ public void Apply(ApplicationModel application)
string template = TemplateFromResource(controller) ?? TemplateFromController(controller);
- if (_registeredControllerNameByTemplate.ContainsKey(template))
+ if (_registeredControllerNameByTemplate.TryGetValue(template, out string? controllerName))
{
throw new InvalidConfigurationException(
- $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template.");
+ $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{controllerName}' was already registered for this template.");
}
_registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!);
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs
index 578643d5db..e8041f0cf2 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs
@@ -8,13 +8,22 @@ namespace JsonApiDotNetCore.Queries.Expressions;
[PublicAPI]
public class LiteralConstantExpression : IdentifierExpression
{
- public string Value { get; }
+ private readonly string _stringValue;
- public LiteralConstantExpression(string text)
+ public object TypedValue { get; }
+
+ public LiteralConstantExpression(object typedValue)
+ : this(typedValue, typedValue.ToString()!)
+ {
+ }
+
+ public LiteralConstantExpression(object typedValue, string stringValue)
{
- ArgumentGuard.NotNull(text);
+ ArgumentGuard.NotNull(typedValue);
+ ArgumentGuard.NotNull(stringValue);
- Value = text;
+ TypedValue = typedValue;
+ _stringValue = stringValue;
}
public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument)
@@ -24,8 +33,8 @@ public override TResult Accept(QueryExpressionVisitor
-/// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows.
+/// Tracks values used for top-level pagination, which is a combined effort from options, query string parsing, resource definition callbacks and
+/// fetching the total number of rows.
///
public interface IPaginationContext
{
///
- /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber.
+ /// The value 1, unless overridden from query string or resource definition. Should not be higher than .
///
PageNumber PageNumber { get; set; }
///
- /// The default page size from options, unless specified in query string. Can be null , which means no paging. Cannot be higher than
- /// options.MaximumPageSize.
+ /// The default page size from options, unless overridden from query string or resource definition. Should not be higher than
+ /// . Can be null , which means pagination is disabled.
///
PageSize? PageSize { get; set; }
@@ -25,12 +26,12 @@ public interface IPaginationContext
bool IsPageFull { get; set; }
///
- /// The total number of resources. null when is set to false .
+ /// The total number of resources, or null when is set to false .
///
int? TotalResourceCount { get; set; }
///
- /// The total number of resource pages. null when is set to false or
+ /// The total number of resource pages, or null when is set to false or
/// is null .
///
int? TotalPageCount { get; }
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs
index b768eb15b1..cdada38fb4 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs
@@ -1,11 +1,11 @@
using System.Collections.Immutable;
-using System.Reflection;
using Humanizer;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
+using JsonApiDotNetCore.Resources.Internal;
namespace JsonApiDotNetCore.Queries.Internal.Parsing;
@@ -141,28 +141,32 @@ protected ComparisonExpression ParseComparison(string operatorName)
: FieldChainRequirements.EndsInAttribute;
QueryExpression leftTerm = ParseCountOrField(leftChainRequirements);
+ Converter rightConstantValueConverter;
+
+ if (leftTerm is CountExpression)
+ {
+ rightConstantValueConverter = GetConstantValueConverterForCount();
+ }
+ else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute)
+ {
+ rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute);
+ }
+ else
+ {
+ // This temporary value never survives; it gets discarded during the second pass below.
+ rightConstantValueConverter = _ => 0;
+ }
EatSingleCharacterToken(TokenKind.Comma);
- QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute);
+ QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter);
EatSingleCharacterToken(TokenKind.CloseParen);
- if (leftTerm is ResourceFieldChainExpression leftChain)
+ if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression)
{
- if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression)
- {
- // Run another pass over left chain to have it fail when chain ends in relationship.
- OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute);
- }
-
- PropertyInfo leftProperty = leftChain.Fields[^1].Property;
-
- if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant)
- {
- string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value);
- rightTerm = new LiteralConstantExpression(id);
- }
+ // Run another pass over left chain to produce an error.
+ OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute);
}
return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm);
@@ -173,16 +177,23 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName)
EatText(matchFunctionName);
EatSingleCharacterToken(TokenKind.OpenParen);
- ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null);
+ ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null);
+ Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType;
+
+ if (targetAttributeType != typeof(string))
+ {
+ throw new QueryParseException("Attribute of type 'String' expected.");
+ }
EatSingleCharacterToken(TokenKind.Comma);
- LiteralConstantExpression constant = ParseConstant();
+ Converter constantValueConverter = stringValue => stringValue;
+ LiteralConstantExpression constant = ParseConstant(constantValueConverter);
EatSingleCharacterToken(TokenKind.CloseParen);
var matchKind = Enum.Parse(matchFunctionName.Pascalize());
- return new MatchTextExpression(targetAttribute, constant, matchKind);
+ return new MatchTextExpression(targetAttributeChain, constant, matchKind);
}
protected AnyExpression ParseAny()
@@ -191,19 +202,20 @@ protected AnyExpression ParseAny()
EatSingleCharacterToken(TokenKind.OpenParen);
ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null);
+ Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]);
EatSingleCharacterToken(TokenKind.Comma);
ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder();
- LiteralConstantExpression constant = ParseConstant();
+ LiteralConstantExpression constant = ParseConstant(constantValueConverter);
constantsBuilder.Add(constant);
while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma)
{
EatSingleCharacterToken(TokenKind.Comma);
- constant = ParseConstant();
+ constant = ParseConstant(constantValueConverter);
constantsBuilder.Add(constant);
}
@@ -211,32 +223,9 @@ protected AnyExpression ParseAny()
IImmutableSet constantSet = constantsBuilder.ToImmutable();
- PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property;
-
- if (targetAttributeProperty.Name == nameof(Identifiable.Id))
- {
- constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty);
- }
-
return new AnyExpression(targetAttribute, constantSet);
}
- private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet,
- PropertyInfo targetAttributeProperty)
- {
- ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder();
-
- foreach (LiteralConstantExpression idConstant in constantSet)
- {
- string stringId = idConstant.Value;
- string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId);
-
- idConstantsBuilder.Add(new LiteralConstantExpression(id));
- }
-
- return idConstantsBuilder.ToImmutable();
- }
-
protected HasExpression ParseHas()
{
EatText(Keywords.Has);
@@ -360,7 +349,7 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem
return ParseFieldChain(chainRequirements, "Count function or field name expected.");
}
- protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements)
+ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter)
{
CountExpression? count = TryParseCount();
@@ -369,7 +358,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen
return count;
}
- IdentifierExpression? constantOrNull = TryParseConstantOrNull();
+ IdentifierExpression? constantOrNull = TryParseConstantOrNull(constantValueConverter);
if (constantOrNull != null)
{
@@ -379,7 +368,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen
return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected.");
}
- protected IdentifierExpression? TryParseConstantOrNull()
+ protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter)
{
if (TokenStack.TryPeek(out Token? nextToken))
{
@@ -392,28 +381,55 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen
if (nextToken.Kind == TokenKind.QuotedText)
{
TokenStack.Pop();
- return new LiteralConstantExpression(nextToken.Value!);
+
+ object constantValue = constantValueConverter(nextToken.Value!);
+ return new LiteralConstantExpression(constantValue, nextToken.Value!);
}
}
return null;
}
- protected LiteralConstantExpression ParseConstant()
+ protected LiteralConstantExpression ParseConstant(Converter constantValueConverter)
{
if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText)
{
- return new LiteralConstantExpression(token.Value!);
+ object constantValue = constantValueConverter(token.Value!);
+ return new LiteralConstantExpression(constantValue, token.Value!);
}
throw new QueryParseException("Value between quotes expected.");
}
- private string DeObfuscateStringId(Type resourceClrType, string stringId)
+ private Converter GetConstantValueConverterForCount()
+ {
+ return stringValue => ConvertStringToType(stringValue, typeof(int));
+ }
+
+ private object ConvertStringToType(string value, Type type)
+ {
+ try
+ {
+ return RuntimeTypeConverter.ConvertType(value, type)!;
+ }
+ catch (FormatException)
+ {
+ throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'.");
+ }
+ }
+
+ private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute)
+ {
+ return stringValue => attribute.Property.Name == nameof(IIdentifiable.Id)
+ ? DeObfuscateStringId(attribute.Type.ClrType, stringValue)
+ : ConvertStringToType(stringValue, attribute.Property.PropertyType);
+ }
+
+ private object DeObfuscateStringId(Type resourceClrType, string stringId)
{
IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType);
tempResource.StringId = stringId;
- return tempResource.GetTypedId().ToString()!;
+ return tempResource.GetTypedId();
}
protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements)
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
index 0a1ff48ca0..e22b4ba86b 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
@@ -114,14 +114,14 @@ private static FilterExpression GetInverseHasOneRelationshipFilter(TId prim
AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType);
var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute));
- return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!));
+ return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!));
}
private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, 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!.ToString()!));
+ var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!));
return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison);
}
@@ -360,12 +360,12 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression?
if (ids.Count == 1)
{
- var constant = new LiteralConstantExpression(ids.Single()!.ToString()!);
+ var constant = new LiteralConstantExpression(ids.Single()!);
filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant);
}
else if (ids.Count > 1)
{
- ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet();
+ ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet();
filter = new AnyExpression(idChain, constants);
}
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
index 1f1c10301a..6a4f43d7e1 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
@@ -136,10 +136,10 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe
if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships)
{
- // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties.
- // And only selecting relationships implicitly means to select all attributes too.
+ // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties.
+ // And only selecting relationships implicitly means to fetch all scalar properties as well.
- IncludeAllAttributes(elementType, propertySelectors);
+ IncludeAllScalarProperties(elementType, propertySelectors);
}
IncludeFields(fieldSelectors, propertySelectors);
@@ -148,7 +148,7 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe
return propertySelectors.Values;
}
- private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors)
+ private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors)
{
IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray();
@@ -185,10 +185,7 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary calls.
///
[PublicAPI]
-public class WhereClauseBuilder : QueryClauseBuilder
+public class WhereClauseBuilder : QueryClauseBuilder
{
private static readonly CollectionConverter CollectionConverter = new();
private static readonly ConstantExpression NullConstant = Expression.Constant(null);
@@ -53,7 +53,7 @@ private Expression WhereExtensionMethodCall(LambdaExpression predicate)
return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate);
}
- public override Expression VisitHas(HasExpression expression, Type? argument)
+ public override Expression VisitHas(HasExpression expression, object? argument)
{
Expression property = Visit(expression.TargetCollection, argument);
@@ -85,7 +85,7 @@ private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Exp
: Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source);
}
- public override Expression VisitIsType(IsTypeExpression expression, Type? argument)
+ public override Expression VisitIsType(IsTypeExpression expression, object? argument)
{
Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor;
TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType);
@@ -101,7 +101,7 @@ public override Expression VisitIsType(IsTypeExpression expression, Type? argume
return Expression.AndAlso(typeCheck, filter);
}
- public override Expression VisitMatchText(MatchTextExpression expression, Type? argument)
+ public override Expression VisitMatchText(MatchTextExpression expression, object? argument)
{
Expression property = Visit(expression.TargetAttribute, argument);
@@ -125,7 +125,7 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type?
return Expression.Call(property, "Contains", null, text);
}
- public override Expression VisitAny(AnyExpression expression, Type? argument)
+ public override Expression VisitAny(AnyExpression expression, object? argument)
{
Expression property = Visit(expression.TargetAttribute, argument);
@@ -133,8 +133,7 @@ public override Expression VisitAny(AnyExpression expression, Type? argument)
foreach (LiteralConstantExpression constant in expression.Constants)
{
- object? value = ConvertTextToTargetType(constant.Value, property.Type);
- valueList.Add(value);
+ valueList.Add(constant.TypedValue);
}
ConstantExpression collection = Expression.Constant(valueList);
@@ -146,7 +145,7 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp
return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value);
}
- public override Expression VisitLogical(LogicalExpression expression, Type? argument)
+ public override Expression VisitLogical(LogicalExpression expression, object? argument)
{
var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument)));
@@ -179,18 +178,18 @@ private static BinaryExpression Compose(Queue argumentQueue, Func().PublicName;
- throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName);
+ throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName);
}
}
}
@@ -403,7 +403,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r
object? rightValueEvaluated =
await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken);
- AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated);
+ AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated);
await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken);
@@ -529,8 +529,6 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored))
{
- AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore);
-
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
index 9af79831b2..41d366b5c3 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
@@ -60,7 +60,7 @@ public interface IResourceDefinition
/// An optional existing pagination, coming from query string. Can be null .
///
///
- /// The changed pagination, or null to use the first page with default size from options. To disable paging, set
+ /// The changed pagination, or null to use the first page with default size from options. To disable pagination, set
/// to null .
///
PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination);
@@ -241,9 +241,9 @@ Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRe
///
///
///
- /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is
- /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of
- /// resources to be removed.
+ /// Identifier of the left resource. The indication "left" specifies that is declared on
+ /// . In contrast to other relationship methods, only the left ID and only the subset of right resources to be removed
+ /// are retrieved from the underlying data store.
///
///
/// The to-many relationship being removed from.
diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
index 89ba115a64..bcb9648320 100644
--- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
+++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
@@ -1,4 +1,3 @@
-using System.Text.Json;
using JetBrains.Annotations;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;
@@ -13,9 +12,9 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _initiallyStoredAttributeValues;
- private IDictionary? _requestAttributeValues;
- private IDictionary? _finallyStoredAttributeValues;
+ private IDictionary? _initiallyStoredAttributeValues;
+ private IDictionary? _requestAttributeValues;
+ private IDictionary? _finallyStoredAttributeValues;
public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields)
{
@@ -50,15 +49,14 @@ public void SetFinallyStoredAttributeValues(TResource resource)
_finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes);
}
- private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes)
+ private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes)
{
- var result = new Dictionary();
+ var result = new Dictionary();
foreach (AttrAttribute attribute in attributes)
{
object? value = attribute.GetValue(resource);
- string json = JsonSerializer.Serialize(value);
- result.Add(attribute.PublicName, json);
+ result.Add(attribute.PublicName, value);
}
return result;
@@ -71,21 +69,21 @@ public bool HasImplicitChanges()
{
foreach (string key in _initiallyStoredAttributeValues.Keys)
{
- if (_requestAttributeValues.TryGetValue(key, out string? requestValue))
+ if (_requestAttributeValues.TryGetValue(key, out object? requestValue))
{
- string actualValue = _finallyStoredAttributeValues[key];
+ object? actualValue = _finallyStoredAttributeValues[key];
- if (requestValue != actualValue)
+ if (!Equals(requestValue, actualValue))
{
return true;
}
}
else
{
- string initiallyStoredValue = _initiallyStoredAttributeValues[key];
- string finallyStoredValue = _finallyStoredAttributeValues[key];
+ object? initiallyStoredValue = _initiallyStoredAttributeValues[key];
+ object? finallyStoredValue = _finallyStoredAttributeValues[key];
- if (initiallyStoredValue != finallyStoredValue)
+ if (!Equals(initiallyStoredValue, finallyStoredValue))
{
return true;
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
index fb86eb084c..c6d32e2648 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
@@ -85,7 +85,7 @@ private static string NoAsyncSuffix(string actionName)
links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship);
}
- if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType))
+ if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Pagination, resourceType))
{
SetPaginationInTopLevelLinks(resourceType!, links);
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
index 0da0ebe14b..59057c266b 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs
@@ -30,7 +30,7 @@ public void Add(IDictionary values)
{
ArgumentGuard.NotNull(values);
- _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]);
+ _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.TryGetValue(key, out object? value) ? value : _meta[key]);
}
///
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs
index d5b421850e..a599ed8eae 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs
@@ -41,7 +41,8 @@ public CarExpressionRewriter(IResourceGraph resourceGraph)
throw new NotSupportedException("Only equality comparisons are possible on Car IDs.");
}
- return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable());
+ string carStringId = (string)rightConstant.TypedValue;
+ return RewriteFilterOnCarStringIds(leftChain, carStringId.AsEnumerable());
}
}
@@ -54,7 +55,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph)
if (IsCarId(property))
{
- string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray();
+ string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray();
return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds);
}
@@ -100,7 +101,7 @@ private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldCha
string licensePlateValue)
{
ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute);
- var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue.ToString()));
+ var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue));
ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute);
var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, new LiteralConstantExpression(licensePlateValue));
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs
index 671123930e..6cd623ac94 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs
@@ -128,8 +128,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error.Title.Should().Be("Failed to clear a required relationship.");
-
- error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " +
- "cannot be cleared because it is a required relationship.");
+ error.Detail.Should().Be("The relationship 'rootDirectory' on resource type 'systemVolumes' cannot be cleared because it is a required relationship.");
}
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs
index 53d6841b10..ff3360be30 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs
@@ -1,5 +1,4 @@
using System.Net;
-using System.Text.Json;
using FluentAssertions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Resources;
@@ -54,11 +53,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(1);
- });
+ responseDocument.Meta.Should().ContainTotal(1);
}
[Fact]
@@ -84,11 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(1);
- });
+ responseDocument.Meta.Should().ContainTotal(1);
}
[Fact]
@@ -108,11 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(0);
- });
+ responseDocument.Meta.Should().ContainTotal(0);
}
[Fact]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs
index ab0a4a4a7e..d70f50de0e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs
@@ -1,5 +1,4 @@
using System.Net;
-using System.Text.Json;
using FluentAssertions;
using JsonApiDotNetCore.Serialization.Objects;
using TestBuildingBlocks;
@@ -57,11 +56,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Included[0].Relationships.Should().BeNull();
responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}");
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(2);
- });
+ responseDocument.Meta.Should().ContainTotal(2);
}
[Fact]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
index b464a15083..b910b7e42e 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs
@@ -304,7 +304,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
}
[Fact]
- public async Task Cannot_filter_equality_on_incompatible_value()
+ public async Task Cannot_filter_equality_on_incompatible_values()
{
// Arrange
var resource = new FilterableResource
@@ -331,9 +331,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("Query creation failed due to incompatible types.");
+ error.Title.Should().Be("The specified filter is invalid.");
error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'.");
- error.Source.Should().BeNull();
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("filter");
}
[Theory]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
index b9fd8e2b2a..69dd7ca706 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs
@@ -696,6 +696,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString));
}
+ [Fact]
+ public async Task Cannot_filter_text_match_on_non_string_value()
+ {
+ // Arrange
+ const string route = "/filterableResources?filter=contains(someInt32,'123')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be("The specified filter is invalid.");
+ error.Detail.Should().Be("Attribute of type 'String' expected.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("filter");
+ }
+
[Theory]
[InlineData("yes", "no", "'yes'")]
[InlineData("two", "one two", "'one','two','three'")]
@@ -842,6 +864,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId);
}
+ [Fact]
+ public async Task Cannot_filter_on_count_with_incompatible_value()
+ {
+ // Arrange
+ const string route = "/filterableResources?filter=equals(count(children),'ABC')";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ error.Title.Should().Be("The specified filter is invalid.");
+ error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'.");
+ error.Source.ShouldNotBeNull();
+ error.Source.Parameter.Should().Be("filter");
+ }
+
[Theory]
[InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")]
[InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs
index 194e73a5dc..9174c84058 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs
@@ -88,7 +88,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource).");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource).");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
@@ -463,7 +463,7 @@ public async Task Cannot_paginate_in_unknown_scope()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -485,7 +485,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
@@ -500,6 +500,7 @@ public async Task Uses_default_page_number_and_size()
Blog blog = _fakers.Blog.Generate();
blog.Posts = _fakers.BlogPost.Generate(3);
+ blog.Posts.ToList().ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet());
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
@@ -507,7 +508,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});
- string route = $"/blogs/{blog.StringId}/posts";
+ string route = $"/blogs/{blog.StringId}/posts?include=labels";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
@@ -519,16 +520,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId);
responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId);
+ responseDocument.Included.ShouldHaveCount(4);
+
responseDocument.Links.ShouldNotBeNull();
responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}");
responseDocument.Links.First.Should().Be(responseDocument.Links.Self);
- responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}?page%5Bnumber%5D=2");
+ responseDocument.Links.Last.Should().Be($"{responseDocument.Links.Self}&page%5Bnumber%5D=2");
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().Be(responseDocument.Links.Last);
}
[Fact]
- public async Task Returns_all_resources_when_paging_is_disabled()
+ public async Task Returns_all_resources_when_pagination_is_disabled()
{
// Arrange
var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService();
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs
index bcbd864d65..6b715f0825 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs
@@ -42,7 +42,7 @@ public async Task Cannot_use_negative_page_number()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("Page number cannot be negative or zero.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -64,7 +64,7 @@ public async Task Cannot_use_zero_page_number()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("Page number cannot be negative or zero.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -123,7 +123,7 @@ public async Task Cannot_use_negative_page_size()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("Page size cannot be negative.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs
index cdbb9ea4be..66cb0dca57 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs
@@ -71,7 +71,7 @@ public async Task Cannot_use_page_number_over_maximum()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -93,7 +93,7 @@ public async Task Cannot_use_zero_page_size()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be("Page size cannot be unconstrained.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
@@ -144,7 +144,7 @@ public async Task Cannot_use_page_size_over_maximum()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}.");
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
index 587b7d8277..da8c60a34f 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs
@@ -49,7 +49,7 @@ public async Task Sets_location_header_for_created_resource()
}
};
- const string route = "/workItems";
+ const string route = "/workItems/";
// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody);
@@ -61,7 +61,7 @@ public async Task Sets_location_header_for_created_resource()
httpResponse.Headers.Location.Should().Be($"/workItems/{newWorkItemId}");
responseDocument.Links.ShouldNotBeNull();
- responseDocument.Links.Self.Should().Be("http://localhost/workItems");
+ responseDocument.Links.Self.Should().Be("http://localhost/workItems/");
responseDocument.Links.First.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs
index 3e1dfcaef0..5828de90d3 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs
@@ -256,9 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error.Title.Should().Be("Failed to clear a required relationship.");
-
- error.Detail.Should().Be($"The relationship 'customer' on resource type 'orders' with ID '{existingOrder.StringId}' " +
- "cannot be cleared because it is a required relationship.");
+ error.Detail.Should().Be("The relationship 'customer' on resource type 'orders' cannot be cleared because it is a required relationship.");
}
[Fact]
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
index db80d4c14b..6176b28a38 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs
@@ -1,5 +1,4 @@
using System.Net;
-using System.Text.Json;
using FluentAssertions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Serialization.Objects;
@@ -238,11 +237,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Id.Should().Be(planets[1].StringId);
responseDocument.Data.ManyValue[1].Id.Should().Be(planets[3].StringId);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(2);
- });
+ responseDocument.Meta.Should().ContainTotal(2);
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
@@ -297,11 +292,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Id.Should().Be(planets[3].StringId);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(1);
- });
+ responseDocument.Meta.Should().ContainTotal(1);
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
@@ -349,11 +340,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId);
responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(2);
- });
+ responseDocument.Meta.Should().ContainTotal(2);
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
@@ -405,11 +392,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId);
responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId);
- responseDocument.Meta.ShouldContainKey("total").With(value =>
- {
- JsonElement element = value.Should().BeOfType().Subject;
- element.GetInt32().Should().Be(2);
- });
+ responseDocument.Meta.Should().ContainTotal(2);
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
index cbc63de58f..cc4583ed4c 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
@@ -21,37 +21,37 @@ public sealed class LinkInclusionTests
[InlineData(LinkTypes.NotConfigured, LinkTypes.None, LinkTypes.None)]
[InlineData(LinkTypes.NotConfigured, LinkTypes.Self, LinkTypes.Self)]
[InlineData(LinkTypes.NotConfigured, LinkTypes.Related, LinkTypes.Related)]
- [InlineData(LinkTypes.NotConfigured, LinkTypes.Paging, LinkTypes.Paging)]
+ [InlineData(LinkTypes.NotConfigured, LinkTypes.Pagination, LinkTypes.Pagination)]
[InlineData(LinkTypes.NotConfigured, LinkTypes.All, LinkTypes.All)]
[InlineData(LinkTypes.None, LinkTypes.NotConfigured, LinkTypes.None)]
[InlineData(LinkTypes.None, LinkTypes.None, LinkTypes.None)]
[InlineData(LinkTypes.None, LinkTypes.Self, LinkTypes.None)]
[InlineData(LinkTypes.None, LinkTypes.Related, LinkTypes.None)]
- [InlineData(LinkTypes.None, LinkTypes.Paging, LinkTypes.None)]
+ [InlineData(LinkTypes.None, LinkTypes.Pagination, LinkTypes.None)]
[InlineData(LinkTypes.None, LinkTypes.All, LinkTypes.None)]
[InlineData(LinkTypes.Self, LinkTypes.NotConfigured, LinkTypes.Self)]
[InlineData(LinkTypes.Self, LinkTypes.None, LinkTypes.Self)]
[InlineData(LinkTypes.Self, LinkTypes.Self, LinkTypes.Self)]
[InlineData(LinkTypes.Self, LinkTypes.Related, LinkTypes.Self)]
- [InlineData(LinkTypes.Self, LinkTypes.Paging, LinkTypes.Self)]
+ [InlineData(LinkTypes.Self, LinkTypes.Pagination, LinkTypes.Self)]
[InlineData(LinkTypes.Self, LinkTypes.All, LinkTypes.Self)]
[InlineData(LinkTypes.Related, LinkTypes.NotConfigured, LinkTypes.Related)]
[InlineData(LinkTypes.Related, LinkTypes.None, LinkTypes.Related)]
[InlineData(LinkTypes.Related, LinkTypes.Self, LinkTypes.Related)]
[InlineData(LinkTypes.Related, LinkTypes.Related, LinkTypes.Related)]
- [InlineData(LinkTypes.Related, LinkTypes.Paging, LinkTypes.Related)]
+ [InlineData(LinkTypes.Related, LinkTypes.Pagination, LinkTypes.Related)]
[InlineData(LinkTypes.Related, LinkTypes.All, LinkTypes.Related)]
- [InlineData(LinkTypes.Paging, LinkTypes.NotConfigured, LinkTypes.Paging)]
- [InlineData(LinkTypes.Paging, LinkTypes.None, LinkTypes.Paging)]
- [InlineData(LinkTypes.Paging, LinkTypes.Self, LinkTypes.Paging)]
- [InlineData(LinkTypes.Paging, LinkTypes.Related, LinkTypes.Paging)]
- [InlineData(LinkTypes.Paging, LinkTypes.Paging, LinkTypes.Paging)]
- [InlineData(LinkTypes.Paging, LinkTypes.All, LinkTypes.Paging)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.NotConfigured, LinkTypes.Pagination)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.None, LinkTypes.Pagination)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.Self, LinkTypes.Pagination)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.Related, LinkTypes.Pagination)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.Pagination, LinkTypes.Pagination)]
+ [InlineData(LinkTypes.Pagination, LinkTypes.All, LinkTypes.Pagination)]
[InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.All)]
[InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.All)]
[InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.All)]
[InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.All)]
- [InlineData(LinkTypes.All, LinkTypes.Paging, LinkTypes.All)]
+ [InlineData(LinkTypes.All, LinkTypes.Pagination, LinkTypes.All)]
[InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All)]
public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected)
{
@@ -117,7 +117,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso
topLevelLinks.Related.Should().BeNull();
}
- if (expected.HasFlag(LinkTypes.Paging))
+ if (expected.HasFlag(LinkTypes.Pagination))
{
topLevelLinks.First.ShouldNotBeNull();
topLevelLinks.Last.ShouldNotBeNull();
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs
index 5dc6e3ab75..615bae48e2 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs
@@ -82,7 +82,7 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes
ErrorObject error = exception.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be(errorMessage);
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[number]");
@@ -118,7 +118,7 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa
ErrorObject error = exception.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- error.Title.Should().Be("The specified paging is invalid.");
+ error.Title.Should().Be("The specified pagination is invalid.");
error.Detail.Should().Be(errorMessage);
error.Source.ShouldNotBeNull();
error.Source.Parameter.Should().Be("page[size]");
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs
index 3fc221c190..97a35603b3 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs
@@ -361,7 +361,7 @@ public void Can_override_capabilities_on_Id_property()
IResourceGraph resourceGraph = builder.Build();
ResourceType resourceType = resourceGraph.GetResourceType();
- AttrAttribute idAttribute = resourceType.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id));
+ AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id));
idAttribute.Capabilities.Should().Be(AttrCapabilities.AllowFilter);
}
diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs
index a295e1eaf9..ee2be771e1 100644
--- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs
+++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs
@@ -2,6 +2,7 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using FluentAssertions;
+using FluentAssertions.Collections;
using FluentAssertions.Numeric;
using FluentAssertions.Primitives;
using JetBrains.Annotations;
@@ -65,4 +66,13 @@ private static string ToJsonString(JsonDocument document)
writer.Flush();
return Encoding.UTF8.GetString(stream.ToArray());
}
+
+ ///
+ /// Asserts that a "meta" dictionary contains a single element named "total" with the specified value.
+ ///
+ [CustomAssertion]
+ public static void ContainTotal(this GenericDictionaryAssertions, string, object?> source, int expectedTotal)
+ {
+ source.ContainKey("total").WhoseValue.Should().BeOfType().Subject.GetInt32().Should().Be(expectedTotal);
+ }
}