OData / AspNetCoreOData

ASP.NET Core OData: A server library built upon ODataLib and ASP.NET Core
Other
459 stars 161 forks source link

It's still not clear how to filter by enum property #1201

Open TooMuchLagWillKillYou opened 8 months ago

TooMuchLagWillKillYou commented 8 months ago

I know that in previous versions of OData we could filter a collection using the numeric representation of an enum, like in this example:

// assuming that the object has a "Status" property of type enum
http://localhost:5000/Controller/Action?$top=10&$skip=0&$filter=(Status eq 1)

But now this seems to be impossible to do since I get this error:

Microsoft.OData.ODataException: A binary operator with incompatible types was detected. Found operand types '_namespace._Status' and 'Edm.Int32' for operator kind 'eq'.

I have been looking for documentation on the topic for the past two days and couldn't find a single line of code that was helpful. My scenario is this: I have migrated a large web application from .NET Framework 4.7 to .NET 6 with the front end being developed with React. Many sections of this application query a list filtered by a "Status" property which is of type enum.

I know that if I could pass the string representation of the enum it would work but this requires editing a lot of components and I cannot do it.

I tried implementing StringAsEnumResolver, but that didn't change a thing. I have also tried to implement a CustomStringAsEnumResolver because I noticed that the method PromoteBinaryOperandTypes() doesn't care if one of the operands is an enum, but the debugger doesn't even hit the breakpoint I put in this class. How can we solve or workaround this? Submit new issue

julealgon commented 8 months ago

Please use the "Discussions" feature for asking questions.

xuzhg commented 8 months ago

@TooMuchLagWillKillYou 'StringAsEnumResolver' should work for string enum value used in $filter, but not for integer value.

You can do it by customizing a resolver. Here's my version:

    public class MyResolver : UnqualifiedODataUriResolver
    {
        private StringAsEnumResolver enumResolver = new StringAsEnumResolver();

        public MyResolver()
        {
            EnableCaseInsensitive = true;
        }

        public override void PromoteBinaryOperandTypes(BinaryOperatorKind binaryOperatorKind, ref SingleValueNode leftNode, ref SingleValueNode rightNode, out IEdmTypeReference typeReference)
        {
            typeReference = null;

            if (leftNode.TypeReference.IsEnum() && rightNode.TypeReference.IsInt32() && rightNode is ConstantNode)
            {
                string text = (((ConstantNode)rightNode).Value).ToString();
                ODataEnumValue val;
                IEdmTypeReference typeRef = leftNode.TypeReference;

                if (TryParseEnum(typeRef.Definition as IEdmEnumType, text, out val))
                {
                    rightNode = new ConstantNode(val, text, typeRef);
                    return;
                }
            }
            else if (rightNode.TypeReference.IsEnum() && leftNode.TypeReference.IsInt32() && leftNode is ConstantNode)
            {
                string text = ((ConstantNode)leftNode).Value.ToString();
                ODataEnumValue val;
                IEdmTypeReference typeRef = rightNode.TypeReference;
                if (TryParseEnum(typeRef.Definition as IEdmEnumType, text, out val))
                {
                    leftNode = new ConstantNode(val, text, typeRef);
                    return;
                }
            }

           enumResolver.PromoteBinaryOperandTypes(binaryOperatorKind, ref leftNode, ref rightNode, out typeReference);
        }

        private static bool TryParseEnum(IEdmEnumType enumType, string value, out ODataEnumValue enumValue)
        {
            long parseResult;
            bool num = enumType.TryParseEnum(value, ignoreCase: true, out parseResult);
            enumValue = null;
            if (num)
            {
                enumValue = new ODataEnumValue(parseResult.ToString(CultureInfo.InvariantCulture), enumType.FullTypeName());
            }
            return num;
        }
    }

And register it in program.cs as:

services.AddControllers().AddOData(opt =>
{
    opt
    .Count().Filter().Expand().Select().OrderBy().SetMaxTop(5)
    .AddRouteComponents("odata", GetEdmOdataModel(), services => services.AddSingleton<ODataUriResolver, MyResolver>())
    ;
});

Then, it should work:

  1. http://localhost:50000/odata/Authors?$filter=Kind eq TestOdata.Models.RoleKind'Poster' (it's by default)
  2. http://localhost:50000/odata/Authors?$filter=Kind eq 2 (it's using MyResolver)
  3. http://localhost:50000/odata/Authors?$filter=Kind eq '2' (It's using StringAsEnumResolver)
  4. http://localhost:50000/odata/Authors?$filter=Kind eq 'Poster' (It's using StringAsEnumResolver)

All four requests return the following same payload:

image

xuzhg commented 8 months ago

@mikepizzo I am thinking it might be helpful for customer to use interger to do enum filter by adding the above resolver logic directly into ODL.

robertmclaws commented 8 months ago

@mikepizzo I am thinking it might be helpful for customer to use interger to do enum filter by adding the above resolver logic directly into ODL.

I think that would be amazing. I'd call it IntOrStringAsEnumResolver and I would inject the existing StringAsEnumResolver in the constructor instead of managing an internal instance.

julealgon commented 8 months ago

One thing I will mention regarding this behavior is that, IMHO, anything that works different than the standard AspNetCore enum handling is only going to cause unnecessary confusion and frustration.

Ideally, OData should not only replicate the behavior, but leverage it. I understand that may not be possible though but figured I'd throw it out there as it would be something I'd investigate if working on this (even if it required working with the AspNetCore team for a solution).

mikepizzo commented 8 months ago

@mikepizzo I am thinking it might be helpful for customer to use interger to do enum filter by adding the above resolver logic directly into ODL.

For comparisons, by default, we should support comparisons to either member names or values. However, note that, according to OData Protocol, either is supposed to be quoted:

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

That said, I don't know that I would be opposed to supporting what amounts to an implicit cast from the numeric enum value to the string numeric value.

TooMuchLagWillKillYou commented 8 months ago

, services => services.AddSingleton<ODataUriResolver, MyResolver>()

I just want to point out that this solves my issue.