AutoMapper / AutoMapper.Extensions.OData

Creates LINQ expressions from ODataQueryOptions and executes the query.
MIT License
140 stars 38 forks source link

Inconsistent Expression generated when filtering an enum on root vs in a subquery #172

Closed wbuck closed 1 year ago

wbuck commented 1 year ago

Source/destination types


// Entities (source) 
public class Product 
{
    public int ProductID { get; set; }
    public SimpleEnum Ranking { get; set; }
}

public class Category
{
    public int CategoryID { get; set; }
    public SimpleEnum EnumProp { get; set; }
    public IEnumerable<Product> EnumerableProducts { get; set; }
}

// Models (destination)
public class ProductModel 
{
    public int ProductID { get; set; }
    public SimpleEnumModel Ranking { get; set; }
}

public class CategoryModel
{
    public int CategoryID { get; set; }
    public SimpleEnumModel EnumProp { get; set; }
    public IEnumerable<Product> EnumerableProducts { get; set; }
}

Mapping configuration

public class ObjectMappings : Profile
{
    public ObjectMappings()
    {
        CreateMap<Address, AddressModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<Category, CategoryModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<DataTypes, DataTypesModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<DerivedCategory, DerivedCategoryModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<DerivedProduct, DerivedProductModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<DynamicProduct, DynamicProductModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<Product, ProductModel>()
            .ForAllMembers(o => o.ExplicitExpansion());
        CreateMap<CompositeKey, CompositeKeyModel>();
    }
}

Version: x.y.z

Seen in master, as well as latest release.

Expected behavior

Consistent behavior when filtering by an enum property on root vs filtering an by an enum property in a subquery.

Actual behavior

When filtering by an enum property on root, the property is converted to an Int32 and compared to a constant integer value. When filtering by an enum property in a subquery the enum values are compared as-is, no conversion to the underlying type.

Steps to reproduce


// Filter by enum property on root.
"/CategoryModel?$filter=EnumProp eq 'First'"

// Filter by enum property in a subquery.
"/CategoryModel?$expand=EnumerableProducts($filter=Ranking eq 'First')"

Above produces the following Expression's:


// Filter by enum property on root generated lambda expression.
// Note the conversion to the underlying enum type.
.Lambda #Lambda1<System.Func`2[AutoMapper.OData.EFCore.Tests.Data.Category,System.Boolean]>(AutoMapper.OData.EFCore.Tests.Data.Category $$it)
{
    (System.Int32)$$it.EnumProp == 0
}

// Filter by enum property in a subquery generated lambda expression.
.Lambda #Lambda3<System.Func`2[AutoMapper.OData.EFCore.Tests.Model.ProductModel,System.Boolean]>(AutoMapper.OData.EFCore.Tests.Model.ProductModel $$it)
{
    $$it.Ranking == .Constant<LogicBuilder.Expressions.Utils.ExpressionBuilder.Operand.ConstantContainer`1[AutoMapper.OData.EFCore.Tests.Model.SimpleEnumModel]>(LogicBuilder.Expressions.Utils.ExpressionBuilder.Operand.ConstantContainer`1[AutoMapper.OData.EFCore.Tests.Model.SimpleEnumModel]).TypedProperty
}

When filtering by an enum property in a subquery no results will be returned (even when there are results to return).

Currently to get around this issue I'm using the following Operator in the FilterHelper in a few locations (I also had to handle the collection that's created for the in operator but I talk about the in operator below):

internal sealed class ConvertEnumToUnderlyingOperator : IExpressionPart
{
    private readonly IExpressionPart expressionPart;

    public ConvertEnumToUnderlyingOperator(IExpressionPart expressionPart)
    {
        this.expressionPart = expressionPart;
    }

    public Expression Build() =>
        Build(expressionPart.Build());

    private Expression GetConstantExpression(ConstantExpression constant, Type underlyingEnumType)
    {
        var property = constant.Value.GetType()
            .GetProperty(nameof(ConstantContainer<object>.TypedProperty));

        if (property is not null)
        {
            object value = property.GetValue(constant.Value);
            return GetConstant(value, underlyingEnumType);
        }

        return GetConstant(constant.Value, underlyingEnumType);

        static Expression GetConstant(object value, Type type) =>
            Expression.Constant
            (
                Convert.ChangeType(value, type),
                type
            );
    }

    private Expression Build(Expression expression)
    {
        Type underlyingType = expression.Type.ToNullableUnderlyingType();

        if (!underlyingType.IsEnum)
            return expression;

        Type underlyingEnumType = underlyingType.GetEnumUnderlyingType();

        if (expression is ParameterExpression)
        {
            return GetConvertExpression(expression, underlyingEnumType);
        }
        else if (expression is MemberExpression memberExpression)
        {
            if (memberExpression.Expression is ConstantExpression constant)
                return GetConstantExpression(constant, underlyingEnumType);
            else
                return GetConvertExpression(expression, underlyingEnumType);
        }

        return expression;
    }

    private Expression GetConvertExpression(Expression expression, Type underlyingEnumType)
    {
        Type conversionType = expression.Type.IsNullableType()
            ? underlyingEnumType.ToNullable()
            : underlyingEnumType;

        return Expression.Convert(expression, conversionType);
    }
}

NOTE there is also an issue with the in operator.


Using the in operator when filtering by an enum property on root throws an System.InvalidOperationException:

No generic method 'Contains' on type 'System.Linq.Enumerable' is compatible with the supplied type arguments and arguments. No type arguments should be provided if the method is non-generic.

This was produced with the following query: "/CategoryModel?$filter=EnumProp in ('First', 'Second')"


This is the generated lambda expression when using the in operator on an enum property in a sub query:

.Lambda #Lambda3<System.Func`2[AutoMapper.OData.EFCore.Tests.Model.ProductModel,System.Boolean]>(AutoMapper.OData.EFCore.Tests.Model.ProductModel $$it)
{
    .Call System.Linq.Enumerable.Contains(
        .Constant<System.Collections.Generic.List`1[AutoMapper.OData.EFCore.Tests.Model.SimpleEnumModel]>(System.Collections.Generic.List`1[AutoMapper.OData.EFCore.Tests.Model.SimpleEnumModel]),
        $$it.Ranking)
}

This was produced with the following query: "/CategoryModel?$expand=EnumerableProducts($filter=Ranking in ('First', 'Second'))"

I would have expected the prior lambda expression to instead look like the following for both filtering on root and in a subquery:


.Lambda #Lambda4<System.Func`2[AutoMapper.OData.EFCore.Tests.Model.ProductModel,System.Boolean]>(AutoMapper.OData.EFCore.Tests.Model.ProductModel $$it)
{
    .Call System.Linq.Enumerable.Contains(
        .Constant<System.Collections.Generic.List`1[System.Int32]>(System.Collections.Generic.List`1[System.Int32]),
        (System.Int32)($$it).Ranking)
}
wbuck commented 1 year ago

OK, It looks like the generated Expression differences between what OData does with ApplyTo and what this library does, does not matter. The providers correctly convert the enum to an integer.

So I think the real bug here is with the in operator throwing an exception when you're filtering a root element.

BlaiseD commented 1 year ago

Consider closing this one and posting a new issue regarding the in operator problem?

wbuck commented 1 year ago

Yeah I think that's the way to go.