33using JetBrains . Annotations ;
44using JsonApiDotNetCore . Configuration ;
55using JsonApiDotNetCore . Queries . Expressions ;
6+ using JsonApiDotNetCore . QueryStrings ;
67using JsonApiDotNetCore . Resources ;
78using JsonApiDotNetCore . Resources . Annotations ;
89using JsonApiDotNetCore . Resources . Internal ;
@@ -13,15 +14,16 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing;
1314public class FilterParser : QueryExpressionParser
1415{
1516 private readonly IResourceFactory _resourceFactory ;
16- private readonly Action < ResourceFieldAttribute , ResourceType , string > ? _validateSingleFieldCallback ;
17+ private readonly IEnumerable < IFilterValueConverter > _filterValueConverters ;
1718 private ResourceType ? _resourceTypeInScope ;
1819
19- public FilterParser ( IResourceFactory resourceFactory , Action < ResourceFieldAttribute , ResourceType , string > ? validateSingleFieldCallback = null )
20+ public FilterParser ( IResourceFactory resourceFactory , IEnumerable < IFilterValueConverter > filterValueConverters )
2021 {
2122 ArgumentGuard . NotNull ( resourceFactory ) ;
23+ ArgumentGuard . NotNull ( filterValueConverters ) ;
2224
2325 _resourceFactory = resourceFactory ;
24- _validateSingleFieldCallback = validateSingleFieldCallback ;
26+ _filterValueConverters = filterValueConverters ;
2527 }
2628
2729 public FilterExpression Parse ( string source , ResourceType resourceTypeInScope )
@@ -135,40 +137,34 @@ protected ComparisonExpression ParseComparison(string operatorName)
135137 EatText ( operatorName ) ;
136138 EatSingleCharacterToken ( TokenKind . OpenParen ) ;
137139
138- // Allow equality comparison of a HasOne relationship with null.
140+ // Allow equality comparison of a to-one relationship with null.
139141 FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator . Equals
140142 ? FieldChainRequirements . EndsInAttribute | FieldChainRequirements . EndsInToOne
141143 : FieldChainRequirements . EndsInAttribute ;
142144
143145 QueryExpression leftTerm = ParseCountOrField ( leftChainRequirements ) ;
144- Converter < string , object > rightConstantValueConverter ;
146+
147+ EatSingleCharacterToken ( TokenKind . Comma ) ;
148+
149+ QueryExpression rightTerm ;
145150
146151 if ( leftTerm is CountExpression )
147152 {
148- rightConstantValueConverter = GetConstantValueConverterForCount ( ) ;
153+ Converter < string , object > rightConstantValueConverter = GetConstantValueConverterForCount ( ) ;
154+ rightTerm = ParseCountOrConstantOrField ( FieldChainRequirements . EndsInAttribute , rightConstantValueConverter ) ;
149155 }
150156 else if ( leftTerm is ResourceFieldChainExpression fieldChain && fieldChain . Fields [ ^ 1 ] is AttrAttribute attribute )
151157 {
152- rightConstantValueConverter = GetConstantValueConverterForAttribute ( attribute ) ;
158+ Converter < string , object > rightConstantValueConverter = GetConstantValueConverterForAttribute ( attribute , typeof ( ComparisonExpression ) ) ;
159+ rightTerm = ParseCountOrConstantOrNullOrField ( FieldChainRequirements . EndsInAttribute , rightConstantValueConverter ) ;
153160 }
154161 else
155162 {
156- // This temporary value never survives; it gets discarded during the second pass below.
157- rightConstantValueConverter = _ => 0 ;
163+ rightTerm = ParseNull ( ) ;
158164 }
159165
160- EatSingleCharacterToken ( TokenKind . Comma ) ;
161-
162- QueryExpression rightTerm = ParseCountOrConstantOrNullOrField ( FieldChainRequirements . EndsInAttribute , rightConstantValueConverter ) ;
163-
164166 EatSingleCharacterToken ( TokenKind . CloseParen ) ;
165167
166- if ( leftTerm is ResourceFieldChainExpression leftChain && leftChain . Fields [ ^ 1 ] is RelationshipAttribute && rightTerm is not NullConstantExpression )
167- {
168- // Run another pass over left chain to produce an error.
169- OnResolveFieldChain ( leftChain . ToString ( ) , FieldChainRequirements . EndsInAttribute ) ;
170- }
171-
172168 return new ComparisonExpression ( comparisonOperator , leftTerm , rightTerm ) ;
173169 }
174170
@@ -178,16 +174,11 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName)
178174 EatSingleCharacterToken ( TokenKind . OpenParen ) ;
179175
180176 ResourceFieldChainExpression targetAttributeChain = ParseFieldChain ( FieldChainRequirements . EndsInAttribute , null ) ;
181- Type targetAttributeType = ( ( AttrAttribute ) targetAttributeChain . Fields [ ^ 1 ] ) . Property . PropertyType ;
182-
183- if ( targetAttributeType != typeof ( string ) )
184- {
185- throw new QueryParseException ( "Attribute of type 'String' expected." ) ;
186- }
177+ var targetAttribute = ( AttrAttribute ) targetAttributeChain . Fields [ ^ 1 ] ;
187178
188179 EatSingleCharacterToken ( TokenKind . Comma ) ;
189180
190- Converter < string , object > constantValueConverter = stringValue => stringValue ;
181+ Converter < string , object > constantValueConverter = GetConstantValueConverterForAttribute ( targetAttribute , typeof ( MatchTextExpression ) ) ;
191182 LiteralConstantExpression constant = ParseConstant ( constantValueConverter ) ;
192183
193184 EatSingleCharacterToken ( TokenKind . CloseParen ) ;
@@ -201,13 +192,14 @@ protected AnyExpression ParseAny()
201192 EatText ( Keywords . Any ) ;
202193 EatSingleCharacterToken ( TokenKind . OpenParen ) ;
203194
204- ResourceFieldChainExpression targetAttribute = ParseFieldChain ( FieldChainRequirements . EndsInAttribute , null ) ;
205- Converter < string , object > constantValueConverter = GetConstantValueConverterForAttribute ( ( AttrAttribute ) targetAttribute . Fields [ ^ 1 ] ) ;
195+ ResourceFieldChainExpression targetAttributeChain = ParseFieldChain ( FieldChainRequirements . EndsInAttribute , null ) ;
196+ var targetAttribute = ( AttrAttribute ) targetAttributeChain . Fields [ ^ 1 ] ;
206197
207198 EatSingleCharacterToken ( TokenKind . Comma ) ;
208199
209200 ImmutableHashSet < LiteralConstantExpression > . Builder constantsBuilder = ImmutableHashSet . CreateBuilder < LiteralConstantExpression > ( ) ;
210201
202+ Converter < string , object > constantValueConverter = GetConstantValueConverterForAttribute ( targetAttribute , typeof ( AnyExpression ) ) ;
211203 LiteralConstantExpression constant = ParseConstant ( constantValueConverter ) ;
212204 constantsBuilder . Add ( constant ) ;
213205
@@ -223,7 +215,7 @@ protected AnyExpression ParseAny()
223215
224216 IImmutableSet < LiteralConstantExpression > constantSet = constantsBuilder . ToImmutable ( ) ;
225217
226- return new AnyExpression ( targetAttribute , constantSet ) ;
218+ return new AnyExpression ( targetAttributeChain , constantSet ) ;
227219 }
228220
229221 protected HasExpression ParseHas ( )
@@ -349,6 +341,25 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem
349341 return ParseFieldChain ( chainRequirements , "Count function or field name expected." ) ;
350342 }
351343
344+ protected QueryExpression ParseCountOrConstantOrField ( FieldChainRequirements chainRequirements , Converter < string , object > constantValueConverter )
345+ {
346+ CountExpression ? count = TryParseCount ( ) ;
347+
348+ if ( count != null )
349+ {
350+ return count ;
351+ }
352+
353+ LiteralConstantExpression ? constant = TryParseConstant ( constantValueConverter ) ;
354+
355+ if ( constant != null )
356+ {
357+ return constant ;
358+ }
359+
360+ return ParseFieldChain ( chainRequirements , "Count function, value between quotes or field name expected." ) ;
361+ }
362+
352363 protected QueryExpression ParseCountOrConstantOrNullOrField ( FieldChainRequirements chainRequirements , Converter < string , object > constantValueConverter )
353364 {
354365 CountExpression ? count = TryParseCount ( ) ;
@@ -368,6 +379,19 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen
368379 return ParseFieldChain ( chainRequirements , "Count function, value between quotes, null or field name expected." ) ;
369380 }
370381
382+ protected LiteralConstantExpression ? TryParseConstant ( Converter < string , object > constantValueConverter )
383+ {
384+ if ( TokenStack . TryPeek ( out Token ? nextToken ) && nextToken . Kind == TokenKind . QuotedText )
385+ {
386+ TokenStack . Pop ( ) ;
387+
388+ object constantValue = constantValueConverter ( nextToken . Value ! ) ;
389+ return new LiteralConstantExpression ( constantValue , nextToken . Value ! ) ;
390+ }
391+
392+ return null ;
393+ }
394+
371395 protected IdentifierExpression ? TryParseConstantOrNull ( Converter < string , object > constantValueConverter )
372396 {
373397 if ( TokenStack . TryPeek ( out Token ? nextToken ) )
@@ -392,37 +416,93 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen
392416
393417 protected LiteralConstantExpression ParseConstant ( Converter < string , object > constantValueConverter )
394418 {
395- if ( TokenStack . TryPop ( out Token ? token ) && token . Kind == TokenKind . QuotedText )
419+ LiteralConstantExpression ? constant = TryParseConstant ( constantValueConverter ) ;
420+
421+ if ( constant == null )
422+ {
423+ throw new QueryParseException ( "Value between quotes expected." ) ;
424+ }
425+
426+ return constant ;
427+ }
428+
429+ protected NullConstantExpression ParseNull ( )
430+ {
431+ if ( TokenStack . TryPop ( out Token ? token ) && token is { Kind : TokenKind . Text , Value : Keywords . Null } )
396432 {
397- object constantValue = constantValueConverter ( token . Value ! ) ;
398- return new LiteralConstantExpression ( constantValue , token . Value ! ) ;
433+ return NullConstantExpression . Instance ;
399434 }
400435
401- throw new QueryParseException ( "Value between quotes expected." ) ;
436+ throw new QueryParseException ( "null expected." ) ;
402437 }
403438
404439 private Converter < string , object > GetConstantValueConverterForCount ( )
405440 {
406441 return stringValue => ConvertStringToType ( stringValue , typeof ( int ) ) ;
407442 }
408443
409- private object ConvertStringToType ( string value , Type type )
444+ private static object ConvertStringToType ( string value , Type type )
410445 {
411446 try
412447 {
413448 return RuntimeTypeConverter . ConvertType ( value , type ) ! ;
414449 }
415- catch ( FormatException )
450+ catch ( FormatException exception )
416451 {
417- throw new QueryParseException ( $ "Failed to convert '{ value } ' of type 'String' to type '{ type . Name } '.") ;
452+ throw new QueryParseException ( $ "Failed to convert '{ value } ' of type 'String' to type '{ type . Name } '.", exception ) ;
418453 }
419454 }
420455
421- private Converter < string , object > GetConstantValueConverterForAttribute ( AttrAttribute attribute )
456+ private Converter < string , object > GetConstantValueConverterForAttribute ( AttrAttribute attribute , Type outerExpressionType )
422457 {
423- return stringValue => attribute . Property . Name == nameof ( Identifiable < object > . Id )
424- ? DeObfuscateStringId ( attribute . Type . ClrType , stringValue )
425- : ConvertStringToType ( stringValue , attribute . Property . PropertyType ) ;
458+ return stringValue =>
459+ {
460+ object ? value = TryConvertFromStringUsingFilterValueConverters ( attribute , stringValue , outerExpressionType ) ;
461+
462+ if ( value != null )
463+ {
464+ return value ;
465+ }
466+
467+ if ( outerExpressionType == typeof ( MatchTextExpression ) )
468+ {
469+ if ( attribute . Property . PropertyType != typeof ( string ) )
470+ {
471+ throw new QueryParseException ( "Attribute of type 'String' expected." ) ;
472+ }
473+ }
474+ else
475+ {
476+ // Partial text matching on an obfuscated ID usually fails.
477+ if ( attribute . Property . Name == nameof ( Identifiable < object > . Id ) )
478+ {
479+ return DeObfuscateStringId ( attribute . Type . ClrType , stringValue ) ;
480+ }
481+ }
482+
483+ return ConvertStringToType ( stringValue , attribute . Property . PropertyType ) ;
484+ } ;
485+ }
486+
487+ private object ? TryConvertFromStringUsingFilterValueConverters ( AttrAttribute attribute , string stringValue , Type outerExpressionType )
488+ {
489+ foreach ( IFilterValueConverter converter in _filterValueConverters )
490+ {
491+ if ( converter . CanConvert ( attribute ) )
492+ {
493+ object result = converter . Convert ( attribute , stringValue , outerExpressionType ) ;
494+
495+ if ( result == null )
496+ {
497+ throw new InvalidOperationException (
498+ $ "Converter '{ converter . GetType ( ) . Name } ' returned null for '{ stringValue } ' on attribute '{ attribute . PublicName } '. Return a sentinel value instead.") ;
499+ }
500+
501+ return result ;
502+ }
503+ }
504+
505+ return null ;
426506 }
427507
428508 private object DeObfuscateStringId ( Type resourceClrType , string stringId )
@@ -436,29 +516,37 @@ protected override IImmutableList<ResourceFieldAttribute> OnResolveFieldChain(st
436516 {
437517 if ( chainRequirements == FieldChainRequirements . EndsInToMany )
438518 {
439- return ChainResolver . ResolveToOneChainEndingInToMany ( _resourceTypeInScope ! , path , FieldChainInheritanceRequirement . Disabled ,
440- _validateSingleFieldCallback ) ;
519+ return ChainResolver . ResolveToOneChainEndingInToMany ( _resourceTypeInScope ! , path , FieldChainInheritanceRequirement . Disabled , ValidateSingleField ) ;
441520 }
442521
443522 if ( chainRequirements == FieldChainRequirements . EndsInAttribute )
444523 {
445524 return ChainResolver . ResolveToOneChainEndingInAttribute ( _resourceTypeInScope ! , path , FieldChainInheritanceRequirement . Disabled ,
446- _validateSingleFieldCallback ) ;
525+ ValidateSingleField ) ;
447526 }
448527
449528 if ( chainRequirements == FieldChainRequirements . EndsInToOne )
450529 {
451- return ChainResolver . ResolveToOneChain ( _resourceTypeInScope ! , path , _validateSingleFieldCallback ) ;
530+ return ChainResolver . ResolveToOneChain ( _resourceTypeInScope ! , path , ValidateSingleField ) ;
452531 }
453532
454533 if ( chainRequirements . HasFlag ( FieldChainRequirements . EndsInAttribute ) && chainRequirements . HasFlag ( FieldChainRequirements . EndsInToOne ) )
455534 {
456- return ChainResolver . ResolveToOneChainEndingInAttributeOrToOne ( _resourceTypeInScope ! , path , _validateSingleFieldCallback ) ;
535+ return ChainResolver . ResolveToOneChainEndingInAttributeOrToOne ( _resourceTypeInScope ! , path , ValidateSingleField ) ;
457536 }
458537
459538 throw new InvalidOperationException ( $ "Unexpected combination of chain requirement flags '{ chainRequirements } '.") ;
460539 }
461540
541+ protected override void ValidateSingleField ( ResourceFieldAttribute field , ResourceType resourceType , string path )
542+ {
543+ if ( field . IsFilterBlocked ( ) )
544+ {
545+ string kind = field is AttrAttribute ? "attribute" : "relationship" ;
546+ throw new QueryParseException ( $ "Filtering on { kind } '{ field . PublicName } ' is not allowed.") ;
547+ }
548+ }
549+
462550 private TResult InScopeOfResourceType < TResult > ( ResourceType resourceType , Func < TResult > action )
463551 {
464552 ResourceType ? backupType = _resourceTypeInScope ;
0 commit comments