AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

IN operator on an ENUM value causes an exception

Open ThomasHeijtink opened this issue 11 months ago • 5 comments

Assemblies affected Which assemblies and versions are known to be affected e.g. ASP.NET Core OData 8.x ASP.NET Core OData 9.1.1

Describe the bug Filtering on an Enum property using the integer value as string in combination with the IN operator causes the following exception:

Microsoft.OData.ODataException: The string '3' is not a valid enumeration type constant.

When filtering on an enum value using just an integer value in combination with the IN operator we get the following exception:

Microsoft.OData.ODataException: Cannot read the value '3' as a quoted JSON string value.

Regular filtering using the EQ operator on any of these two representation of an enum value works fine.

Reproduce steps Take the EnumsController. Change the Get to using the ODataQueryOptions<Employee> directly (rather than using the EnableQuery attribute) and issue a GET to http://localhost:5000/convention/employees?$filter=Gender in (2,3) or http://localhost:5000/convention/employees?$filter=Gender in ('2','3').

Data Model Employee model in the ODataCustomizedSample sample project.

EDM (CSDL) Model Not applicable

Request/Response Not relevant

Expected behavior The endpoint to return employees of either gender.

Screenshots Not applicable

Additional context Exception + stacktrace with quoted numerical enum value:

Microsoft.OData.ODataException: The string '2' is not a valid enumeration type constant.
   at Microsoft.OData.UriParser.MetadataBindingUtils.VerifyCollectionNode(CollectionNode node, Boolean enableCaseInsensitive)
   at Microsoft.OData.UriParser.InBinder.BindInOperator(InToken inToken, BindingState state)
   at Microsoft.OData.UriParser.MetadataBinder.BindIn(InToken inToken)
   at Microsoft.OData.UriParser.MetadataBinder.Bind(QueryToken token)
   at Microsoft.OData.UriParser.FilterBinder.BindFilter(QueryToken filter)
   at Microsoft.OData.UriParser.ODataQueryOptionParser.ParseFilterImplementation(String filter, ODataUriParserConfiguration configuration, ODataPathInfo odataPathInfo)
   at Microsoft.OData.UriParser.ODataQueryOptionParser.ParseFilter()
   at Microsoft.AspNetCore.OData.Query.FilterQueryOption.get_FilterClause() in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\Query\FilterQueryOption.cs:line 118
   at Microsoft.AspNetCore.OData.Query.FilterQueryOption.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\Query\FilterQueryOption.cs:line 161
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptions.cs:line 387
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptionsOfT.cs:line 95
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptions.cs:line 301
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptionsOfT.cs:line 83
   at ODataCustomizedSample.Controller.EmployeesController.Get(ODataQueryOptions`1 queryOptions) in C:\AspNetCoreOData\sample\ODataCustomizedSample\Controllers\EnumsController.cs:line 83
   at lambda_method2(Closure, Object, Object[])
   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute(ActionContext actionContext, IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(ControllerActionInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.OData.Routing.ODataRouteDebugMiddleware.Invoke(HttpContext context) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Routing\ODataRouteDebugMiddleware.cs:line 80
   at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

Exception + stacktrace without quoted numerical enum value:

Microsoft.OData.ODataException: Cannot read the value '2' as a quoted JSON string value.
   at Microsoft.OData.Json.JsonReaderExtensions.ReadStringValue(IJsonReader jsonReader)
   at Microsoft.OData.Json.ODataJsonPropertyAndValueDeserializer.ReadEnumValue(Boolean insideJsonObjectValue, IEdmEnumTypeReference expectedValueTypeReference, Boolean validateNullValue, String propertyName)
   at Microsoft.OData.Json.ODataJsonPropertyAndValueDeserializer.ReadNonEntityValueImplementation(String payloadTypeName, IEdmTypeReference expectedTypeReference, PropertyAndAnnotationCollector propertyAndAnnotationCollector, CollectionWithoutExpectedTypeValidator collectionValidator, Boolean validateNullValue, Boolean isTopLevelPropertyValue, Boolean insideResourceValue, String propertyName, Nullable`1 isDynamicProperty)
   at Microsoft.OData.Json.ODataJsonPropertyAndValueDeserializer.ReadCollectionValue(IEdmCollectionTypeReference collectionValueTypeReference, String payloadTypeName, ODataTypeAnnotation typeAnnotation)
   at Microsoft.OData.Json.ODataJsonPropertyAndValueDeserializer.ReadNonEntityValueImplementation(String payloadTypeName, IEdmTypeReference expectedTypeReference, PropertyAndAnnotationCollector propertyAndAnnotationCollector, CollectionWithoutExpectedTypeValidator collectionValidator, Boolean validateNullValue, Boolean isTopLevelPropertyValue, Boolean insideResourceValue, String propertyName, Nullable`1 isDynamicProperty)
   at Microsoft.OData.Json.ODataJsonPropertyAndValueDeserializer.ReadNonEntityValue(String payloadTypeName, IEdmTypeReference expectedValueTypeReference, PropertyAndAnnotationCollector propertyAndAnnotationCollector, CollectionWithoutExpectedTypeValidator collectionValidator, Boolean validateNullValue, Boolean isTopLevelPropertyValue, Boolean insideResourceValue, String propertyName, Nullable`1 isDynamicProperty)
   at Microsoft.OData.ODataUriConversionUtils.ConvertFromResourceOrCollectionValue(String value, IEdmModel model, IEdmTypeReference typeReference)
   at Microsoft.OData.ODataUriConversionUtils.ConvertFromCollectionValue(String value, IEdmModel model, IEdmTypeReference typeReference)
   at Microsoft.OData.UriParser.InBinder.GetCollectionOperandFromToken(QueryToken queryToken, IEdmTypeReference expectedType, IEdmModel model)
   at Microsoft.OData.UriParser.InBinder.BindInOperator(InToken inToken, BindingState state)
   at Microsoft.OData.UriParser.MetadataBinder.BindIn(InToken inToken)
   at Microsoft.OData.UriParser.MetadataBinder.Bind(QueryToken token)
   at Microsoft.OData.UriParser.FilterBinder.BindFilter(QueryToken filter)
   at Microsoft.OData.UriParser.ODataQueryOptionParser.ParseFilterImplementation(String filter, ODataUriParserConfiguration configuration, ODataPathInfo odataPathInfo)
   at Microsoft.OData.UriParser.ODataQueryOptionParser.ParseFilter()
   at Microsoft.AspNetCore.OData.Query.FilterQueryOption.get_FilterClause() in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\Query\FilterQueryOption.cs:line 118
   at Microsoft.AspNetCore.OData.Query.FilterQueryOption.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\Query\FilterQueryOption.cs:line 161
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptions.cs:line 387
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query, ODataQuerySettings querySettings) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptionsOfT.cs:line 95
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptions.cs:line 301
   at Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query) in C:\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Query\ODataQueryOptionsOfT.cs:line 83
   at ODataCustomizedSample.Controller.EmployeesController.Get(ODataQueryOptions`1 queryOptions) in C:\AspNetCoreOData\sample\ODataCustomizedSample\Controllers\EnumsController.cs:line 83
   at lambda_method2(Closure, Object, Object[])
   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute(ActionContext actionContext, IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(ControllerActionInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.OData.Routing.ODataRouteDebugMiddleware.Invoke(HttpContext context) in C:\Users\tomhe\RiderProjects\AspNetCoreOData\src\Microsoft.AspNetCore.OData\Routing\ODataRouteDebugMiddleware.cs:line 80
   at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

ThomasHeijtink avatar Dec 16 '24 13:12 ThomasHeijtink

The OData standard ABNF for the enum rule is:

enum = [ qualifiedEnumTypeName ] SQUOTE enumValue SQUOTE enumValue = singleEnumValue *( COMMA singleEnumValue ) singleEnumValue = enumerationMember / enumMemberValue enumMemberValue = int64Value

So I believe the "quoted" request http://localhost:5000/convention/employees?$filter=Gender in ('2','3') is the correct syntax. From your stack trace, it appears the offending line is here where we check only if there's an enum member name that matches the value provided in the filter expression.

We should update this logic to allow for enum member values if no matching enum member name is found to match.

corranrogue9 avatar Dec 16 '24 19:12 corranrogue9

@corranrogue9

From your stack trace, it appears the offending line is here where we check only if there's an enum member name that matches the value provided in the filter expression.

We should update this logic to allow for enum member values if no matching enum member name is found to match.

Is there a reason such code doesn't rely on something like Enum.TryParse? That would handle both text as well as numeric forms.

julealgon avatar Dec 16 '24 22:12 julealgon

@ThomasHeijtink also, is there a reason that you are preferring to use the integer value for the enum rather than the member names?

@julealgon that sounds like the right approach, I'll double check that the standard doesn't have any quirk that prevents using it, but that's probably what I'll go with, thanks!

corranrogue9 avatar Dec 17 '24 17:12 corranrogue9

@corranrogue9 thanks for asking. It's mainly to keep queries small and slightly more robust and more versatile. Small is evident. Robust because a front-end or other clients don't require the most recent name/version in case we change it at the backend. Versatile because you can also use flagged enums.

ThomasHeijtink avatar Dec 17 '24 17:12 ThomasHeijtink

@corranrogue9

So I believe the "quoted" request http://localhost:5000/convention/employees?$filter=Gender in ('2','3') is the correct syntax.

Just wanted to note that when using the equality operator ODATA seems to accept unquoted integers as well. This might throw people off when using it with different operators like in. Personally, I would have preferred accepting it as pure integers. It seems to make most sense to me. For example, using the greater than or when aggregating enum values seem more intuitive. But I take any win I can get at this point 😉

ThomasHeijtink avatar Dec 17 '24 18:12 ThomasHeijtink