Skip to content

Commit 5da2ad6

Browse files
committed
Add extensibility point for pluggable filter value conversions
1 parent f818c8f commit 5da2ad6

18 files changed

+542
-23
lines changed

benchmarks/QueryString/QueryStringParserBenchmarks.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public QueryStringParserBenchmarks()
3838
var resourceFactory = new ResourceFactory(new ServiceContainer());
3939

4040
var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options);
41-
var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options);
41+
var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options, Enumerable.Empty<IFilterValueConverter>());
4242
var sortReader = new SortQueryStringParameterReader(request, resourceGraph);
4343
var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph);
4444
var paginationReader = new PaginationQueryStringParameterReader(request, resourceGraph, options);

src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using JetBrains.Annotations;
44
using JsonApiDotNetCore.Configuration;
55
using JsonApiDotNetCore.Queries.Expressions;
6+
using JsonApiDotNetCore.QueryStrings;
67
using JsonApiDotNetCore.Resources;
78
using JsonApiDotNetCore.Resources.Annotations;
89
using JsonApiDotNetCore.Resources.Internal;
@@ -13,14 +14,18 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing;
1314
public class FilterParser : QueryExpressionParser
1415
{
1516
private readonly IResourceFactory _resourceFactory;
17+
private readonly IEnumerable<IFilterValueConverter> _filterValueConverters;
1618
private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback;
1719
private ResourceType? _resourceTypeInScope;
1820

19-
public FilterParser(IResourceFactory resourceFactory, Action<ResourceFieldAttribute, ResourceType, string>? validateSingleFieldCallback = null)
21+
public FilterParser(IResourceFactory resourceFactory, IEnumerable<IFilterValueConverter> filterValueConverters,
22+
Action<ResourceFieldAttribute, ResourceType, string>? validateSingleFieldCallback = null)
2023
{
2124
ArgumentGuard.NotNull(resourceFactory);
25+
ArgumentGuard.NotNull(filterValueConverters);
2226

2327
_resourceFactory = resourceFactory;
28+
_filterValueConverters = filterValueConverters;
2429
_validateSingleFieldCallback = validateSingleFieldCallback;
2530
}
2631

@@ -153,7 +158,7 @@ protected ComparisonExpression ParseComparison(string operatorName)
153158
}
154159
else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute)
155160
{
156-
Converter<string, object> rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute);
161+
Converter<string, object> rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute, typeof(ComparisonExpression));
157162
rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter);
158163
}
159164
else
@@ -172,16 +177,11 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName)
172177
EatSingleCharacterToken(TokenKind.OpenParen);
173178

174179
ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null);
175-
Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType;
176-
177-
if (targetAttributeType != typeof(string))
178-
{
179-
throw new QueryParseException("Attribute of type 'String' expected.");
180-
}
180+
var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
181181

182182
EatSingleCharacterToken(TokenKind.Comma);
183183

184-
Converter<string, object> constantValueConverter = stringValue => stringValue;
184+
Converter<string, object> constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute, typeof(MatchTextExpression));
185185
LiteralConstantExpression constant = ParseConstant(constantValueConverter);
186186

187187
EatSingleCharacterToken(TokenKind.CloseParen);
@@ -197,12 +197,12 @@ protected AnyExpression ParseAny()
197197

198198
ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null);
199199
var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1];
200-
Converter<string, object> constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute);
201200

202201
EatSingleCharacterToken(TokenKind.Comma);
203202

204203
ImmutableHashSet<LiteralConstantExpression>.Builder constantsBuilder = ImmutableHashSet.CreateBuilder<LiteralConstantExpression>();
205204

205+
Converter<string, object> constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute, typeof(AnyExpression));
206206
LiteralConstantExpression constant = ParseConstant(constantValueConverter);
207207
constantsBuilder.Add(constant);
208208

@@ -444,23 +444,68 @@ private Converter<string, object> GetConstantValueConverterForCount()
444444
return stringValue => ConvertStringToType(stringValue, typeof(int));
445445
}
446446

447-
private object ConvertStringToType(string value, Type type)
447+
private static object ConvertStringToType(string value, Type type)
448448
{
449449
try
450450
{
451451
return RuntimeTypeConverter.ConvertType(value, type)!;
452452
}
453-
catch (FormatException)
453+
catch (FormatException exception)
454454
{
455-
throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'.");
455+
throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'.", exception);
456456
}
457457
}
458458

459-
private Converter<string, object> GetConstantValueConverterForAttribute(AttrAttribute attribute)
459+
private Converter<string, object> GetConstantValueConverterForAttribute(AttrAttribute attribute, Type outerExpressionType)
460460
{
461-
return stringValue => attribute.Property.Name == nameof(Identifiable<object>.Id)
462-
? DeObfuscateStringId(attribute.Type.ClrType, stringValue)
463-
: ConvertStringToType(stringValue, attribute.Property.PropertyType);
461+
return stringValue =>
462+
{
463+
object? value = TryConvertFromStringUsingFilterValueConverters(attribute, stringValue, outerExpressionType);
464+
465+
if (value != null)
466+
{
467+
return value;
468+
}
469+
470+
if (outerExpressionType == typeof(MatchTextExpression))
471+
{
472+
if (attribute.Property.PropertyType != typeof(string))
473+
{
474+
throw new QueryParseException("Attribute of type 'String' expected.");
475+
}
476+
}
477+
else
478+
{
479+
// Partial text matching on an obfuscated ID usually fails.
480+
if (attribute.Property.Name == nameof(Identifiable<object>.Id))
481+
{
482+
return DeObfuscateStringId(attribute.Type.ClrType, stringValue);
483+
}
484+
}
485+
486+
return ConvertStringToType(stringValue, attribute.Property.PropertyType);
487+
};
488+
}
489+
490+
private object? TryConvertFromStringUsingFilterValueConverters(AttrAttribute attribute, string stringValue, Type outerExpressionType)
491+
{
492+
foreach (IFilterValueConverter converter in _filterValueConverters)
493+
{
494+
if (converter.CanConvert(attribute))
495+
{
496+
object result = converter.Convert(attribute, stringValue, outerExpressionType);
497+
498+
if (result == null)
499+
{
500+
throw new InvalidOperationException(
501+
$"Converter '{converter.GetType().Name}' returned null for '{stringValue}' on attribute '{attribute.PublicName}'. Return a sentinel value instead.");
502+
}
503+
504+
return result;
505+
}
506+
}
507+
508+
return null;
464509
}
465510

466511
private object DeObfuscateStringId(Type resourceClrType, string stringId)

src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ public QueryParseException(string message)
99
: base(message)
1010
{
1111
}
12+
13+
public QueryParseException(string message, Exception innerException)
14+
: base(message, innerException)
15+
{
16+
}
1217
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Queries.Expressions;
3+
using JsonApiDotNetCore.Queries.Internal.Parsing;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCore.QueryStrings;
8+
9+
/// <summary>
10+
/// Provides conversion of a single-quoted value that occurs in a filter function of a query string.
11+
/// </summary>
12+
[PublicAPI]
13+
public interface IFilterValueConverter
14+
{
15+
/// <summary>
16+
/// Indicates whether this converter can be used for the specified <see cref="AttrAttribute" />.
17+
/// </summary>
18+
/// <param name="attribute">
19+
/// The JSON:API attribute this conversion applies to.
20+
/// </param>
21+
bool CanConvert(AttrAttribute attribute);
22+
23+
/// <summary>
24+
/// Converts <paramref name="value" /> to the specified <paramref name="attribute" />.
25+
/// </summary>
26+
/// <param name="attribute">
27+
/// The JSON:API attribute this conversion applies to.
28+
/// </param>
29+
/// <param name="value">
30+
/// The literal text (without the surrounding single quotes) from the query string.
31+
/// </param>
32+
/// <param name="outerExpressionType">
33+
/// The filter function this conversion applies to, which can be <see cref="ComparisonExpression" />, <see cref="AnyExpression" /> or
34+
/// <see cref="MatchTextExpression" />.
35+
/// </param>
36+
/// <returns>
37+
/// The converted value. Must not be null. In case the type differs from the resource property type, use a
38+
/// <see cref="QueryExpressionRewriter{TArgument}" /> from <see cref="IResourceDefinition{TResource,TId}.OnApplyFilter" /> to produce a valid filter.
39+
/// </returns>
40+
/// <exception cref="QueryParseException">
41+
/// The conversion failed because <paramref name="value" /> is invalid.
42+
/// </exception>
43+
object Convert(AttrAttribute attribute, string value, Type outerExpressionType);
44+
}

src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil
2828

2929
public bool AllowEmptyValue => false;
3030

31-
public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options)
31+
public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options,
32+
IEnumerable<IFilterValueConverter> filterValueConverters)
3233
: base(request, resourceGraph)
3334
{
3435
ArgumentGuard.NotNull(options);
36+
ArgumentGuard.NotNull(filterValueConverters);
3537

3638
_options = options;
3739
_scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany);
38-
_filterParser = new FilterParser(resourceFactory, ValidateSingleField);
40+
_filterParser = new FilterParser(resourceFactory, filterValueConverters, ValidateSingleField);
3941
}
4042

4143
protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path)

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ public sealed class Appointment : Identifiable<int>
1919

2020
[Attr]
2121
public DateTimeOffset EndTime { get; set; }
22+
23+
[HasMany]
24+
public IList<Reminder> Reminders { get; set; } = new List<Reminder>();
2225
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
using JsonApiDotNetCore.Resources;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
7+
8+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
9+
public class FilterRewritingResourceDefinition<TResource, TId> : JsonApiResourceDefinition<TResource, TId>
10+
where TResource : class, IIdentifiable<TId>
11+
{
12+
public FilterRewritingResourceDefinition(IResourceGraph resourceGraph)
13+
: base(resourceGraph)
14+
{
15+
}
16+
17+
public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter)
18+
{
19+
if (existingFilter != null)
20+
{
21+
var rewriter = new FilterTimeRangeRewriter();
22+
return (FilterExpression)rewriter.Visit(existingFilter, null)!;
23+
}
24+
25+
return existingFilter;
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
3+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
4+
5+
internal sealed class FilterTimeRangeRewriter : QueryExpressionRewriter<object?>
6+
{
7+
private static readonly Dictionary<ComparisonOperator, ComparisonOperator> InverseComparisonOperatorTable = new()
8+
{
9+
[ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan,
10+
[ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual,
11+
[ComparisonOperator.Equals] = ComparisonOperator.Equals,
12+
[ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan,
13+
[ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual
14+
};
15+
16+
public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument)
17+
{
18+
if (expression.Right is LiteralConstantExpression { TypedValue: TimeRange timeRange })
19+
{
20+
var offsetComparison =
21+
new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator,
22+
expression.Left, new LiteralConstantExpression(timeRange.Time + timeRange.Offset));
23+
24+
ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual
25+
? new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual,
26+
expression.Left, new LiteralConstantExpression(timeRange.Time))
27+
: null;
28+
29+
return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison);
30+
}
31+
32+
return base.VisitComparison(expression, argument);
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
using JsonApiDotNetCore.Queries.Internal.Parsing;
3+
using JsonApiDotNetCore.QueryStrings;
4+
using JsonApiDotNetCore.Resources.Annotations;
5+
using JsonApiDotNetCore.Resources.Internal;
6+
using Microsoft.AspNetCore.Authentication;
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
9+
10+
internal sealed class RelativeTimeFilterValueConverter : IFilterValueConverter
11+
{
12+
private readonly ISystemClock _systemClock;
13+
14+
public RelativeTimeFilterValueConverter(ISystemClock systemClock)
15+
{
16+
_systemClock = systemClock;
17+
}
18+
19+
public bool CanConvert(AttrAttribute attribute)
20+
{
21+
return attribute.Type.ClrType == typeof(Reminder) && attribute.Property.PropertyType == typeof(DateTime);
22+
}
23+
24+
public object Convert(AttrAttribute attribute, string value, Type outerExpressionType)
25+
{
26+
// A leading +/- indicates a relative value, based on the current time.
27+
28+
if (value.Length > 1 && value[0] is '+' or '-')
29+
{
30+
if (outerExpressionType != typeof(ComparisonExpression))
31+
{
32+
throw new QueryParseException("A relative time can only be used in a comparison function.");
33+
}
34+
35+
var timeSpan = ConvertStringValueTo<TimeSpan>(value[1..]);
36+
TimeSpan offset = value[0] == '-' ? -timeSpan : timeSpan;
37+
return new TimeRange(_systemClock.UtcNow.UtcDateTime, offset);
38+
}
39+
40+
return ConvertStringValueTo<DateTime>(value);
41+
}
42+
43+
private static T ConvertStringValueTo<T>(string value)
44+
{
45+
try
46+
{
47+
return (T)RuntimeTypeConverter.ConvertType(value, typeof(T))!;
48+
}
49+
catch (FormatException exception)
50+
{
51+
throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{typeof(T).Name}'.", exception);
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)