How to generate IQueryable<T> that could be used by EF Core 6 to produce a parameterized query?

33 Views Asked by At

I implemented an extension method ApplyFilter like this:

public static IQueryable<T> ApplyFilter<T>(this IQueryable<T> query, FilterNode filter)
{
    if (filter == null)
        return query;

    // Create a parameter expression for the queryable entity
    ParameterExpression parameter = Expression.Parameter(typeof(T), "entity");

    List<FilterNode> filters = new List<FilterNode>() { filter };

    // Build the predicate expression
    Expression<Func<T, bool>> predicate = BuildPredicate<T>(filters, parameter);

    // Apply the predicate to the query
    return query.Where(predicate);
}

private static Expression<Func<T, bool>> BuildPredicate<T>(List<FilterNode> filters, ParameterExpression parameter)
{
    Expression combinedExpression = null;

    foreach (var filter in filters)
    {
        Expression filterExpression;

        if (filter is FilterCriteriaNode criteriaNode)
        {
            filterExpression = BuildCriteriaExpression(criteriaNode, parameter);
        }
        else if (filter is FilterGroupNode groupNode)
        {
            filterExpression = BuildGroupExpression<T>(groupNode, parameter);
        }
        else
        {
            throw new ArgumentException($"Unknown filter node type: {filter.GetType().Name}");
        }

        if (filterExpression != null)
        {
            combinedExpression = combinedExpression == null
                ? filterExpression
                : (filter.Operator == LogicalOperator.Or
                    ? Expression.OrElse(combinedExpression, filterExpression)
                    : Expression.AndAlso(combinedExpression, filterExpression));
        }
    }

    return combinedExpression == null ? null : Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
}

private static Expression BuildCriteriaExpression(FilterCriteriaNode criteriaNode, ParameterExpression parameter)
{
    string[] propertyNames = criteriaNode.FieldName.Split('.');
    Expression propertyAccess = parameter;

    foreach (string propertyName in propertyNames)
    {
        propertyAccess = Expression.Property(propertyAccess, propertyName);
    }

    Expression propertyExpression = propertyAccess;
    Type propertyType = propertyExpression.Type;

    // If the property is nullable, handle the null checks
    if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
    {
        propertyExpression = Expression.Property(propertyExpression, "Value");
        propertyType = Nullable.GetUnderlyingType(propertyType);
    }

    // Convert the constant value to the appropriate type
    Expression constant;
    if (criteriaNode.Operator == ComparisonOperator.Any || criteriaNode.Operator == ComparisonOperator.Between)
    {
        // Split the string value by comma and convert each part to the appropriate type
        string[] parts = criteriaNode.Value.Split(',');
        var constantValues = parts.Select(part => string.IsNullOrEmpty(part) ? null : Convert.ChangeType(part.Trim(), propertyType)).ToArray();

        if (criteriaNode.Operator == ComparisonOperator.Between)
        {
            // For "Between" operator, constantValues is an array with two elements
            var min = Expression.Constant(constantValues[0], propertyType);
            var max = Expression.Constant(constantValues[1], propertyType);
            return Expression.AndAlso(
                Expression.GreaterThanOrEqual(propertyExpression, min),
                Expression.LessThanOrEqual(propertyExpression, max)
            );
        }
        else // For "Any" operator
        {
            // Get the underlying type of the property
            Type propertyUnderlyingType = propertyType;
            if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
            {
                propertyUnderlyingType = Nullable.GetUnderlyingType(propertyType);
            }

            // Convert constantValue to IEnumerable<T>
            var convertedValue = ((IEnumerable)constantValues).Cast<object>().Select(x => Convert.ChangeType(x, propertyUnderlyingType)).ToArray();

            // Create array initializer expression
            var arrayInitializer = convertedValue.Select(x => Expression.Constant(x, propertyUnderlyingType));

            // Create array expression using array initializer
            var arrayExpression = Expression.NewArrayInit(propertyUnderlyingType, arrayInitializer);

            // Call the Contains method
            var containsMethod = typeof(Enumerable).GetMethods()
                .First(m => m.Name == "Contains" && m.GetParameters().Length == 2)
                .MakeGenericMethod(propertyUnderlyingType);

            return Expression.Call(null, containsMethod, arrayExpression, propertyExpression);
        }
    }
    else // For other operators
    {
        // Handle null values for Equal and NotEqual operators
        if (criteriaNode.Value == null)
        {
            if (criteriaNode.Operator == ComparisonOperator.Equal)
            {
                return Expression.Equal(propertyExpression, Expression.Constant(null, propertyType));
            }
            else if (criteriaNode.Operator == ComparisonOperator.NotEqual)
            {
                if (propertyType.IsValueType && Nullable.GetUnderlyingType(propertyType) != null)
                {
                    // Property type is nullable
                    return Expression.Equal(propertyExpression, Expression.Constant(null, propertyType));
                }
                else if (propertyType.IsValueType)
                {
                    // Property type is a non-nullable value type
                    // We can't directly compare it with null
                    // You may decide what behavior to implement here
                    // For example, throwing an exception or returning a constant false expression
                    throw new InvalidOperationException($"Cannot compare non-nullable value type {propertyType} with null.");
                }
                else
                {
                    // Property type is a reference type
                    return Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(object)));
                }
            }
            else
            {
                throw new NotSupportedException($"Comparison operator {criteriaNode.Operator} is not supported with null value.");
            }
        }
        else // For non-null values
        {
            var constantValue = Convert.ChangeType(criteriaNode.Value, propertyType);
            constant = Expression.Constant(constantValue);
        }

        // Build the appropriate comparison expression based on the operator
        switch (criteriaNode.Operator)
        {
            case ComparisonOperator.Equal:
                return Expression.Equal(propertyExpression, constant);
            case ComparisonOperator.NotEqual:
                return Expression.NotEqual(propertyExpression, constant);
            case ComparisonOperator.Contains:
                // Assuming the property is of type string, you can call the Contains method
                return Expression.Call(propertyExpression, typeof(string).GetMethod("Contains", new[] { typeof(string) }), constant);
            case ComparisonOperator.StartWith:
                return Expression.Call(propertyExpression, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), constant);
            case ComparisonOperator.GreaterThan:
                return Expression.GreaterThan(propertyExpression, constant);
            case ComparisonOperator.LessThan:
                return Expression.LessThan(propertyExpression, constant);
            case ComparisonOperator.GreaterThanOrEqual:
                return Expression.GreaterThanOrEqual(propertyExpression, constant);
            case ComparisonOperator.LessThanOrEqual:
                return Expression.LessThanOrEqual(propertyExpression, constant);
            default:
                throw new NotSupportedException($"Comparison operator {criteriaNode.Operator} is not supported.");
        }
    }
}

private static Expression BuildGroupExpression<T>(FilterGroupNode groupNode, ParameterExpression parameter)
{
    if (groupNode.Children == null || !groupNode.Children.Any())
        throw new ArgumentException("Filter group must have child nodes.");

    Expression combinedExpression = null;

    foreach (var childFilter in groupNode.Children)
    {
        Expression childExpression;

        if (childFilter is FilterCriteriaNode || childFilter is FilterGroupNode)
        {
            childExpression = BuildPredicate<T>(new List<FilterNode> { childFilter }, parameter).Body;
        }
        else
        {
            throw new ArgumentException($"Unknown filter node type: {childFilter.GetType().Name}");
        }

        combinedExpression = combinedExpression == null
            ? childExpression
            : (groupNode.Operator == LogicalOperator.Or
                ? Expression.OrElse(combinedExpression, childExpression)
                : Expression.AndAlso(combinedExpression, childExpression));
    }

    return combinedExpression;
}

When I am calling this method, it didn't generate a parameterized SQL query:

FAMSContext ctx = new FAMSContext();

var query = ctx
    .ActiveAssets(null)
    .ApplyFilter(new FilterCriteriaNode { FieldName = "Model.BrandId", Operator = ComparisonOperator.Equal, Value = "2618" })
    .SelectBasicAssetModel();

string sql = query.ToQueryString();

This is the generated SQL:

SELECT [a].[AssetId], [a].[AssetCode], [a].[SerialNumber], [a].[LocationId], [m].[ModelName], [m].[Description] AS [ModelDescription], [b].[BrandName], [a0].[AssetTypeName], [c].[CategoryName]
FROM [Asset] AS [a]
INNER JOIN [Model] AS [m] ON [a].[ModelId] = [m].[ModelId]
INNER JOIN [Brand] AS [b] ON [m].[BrandId] = [b].[BrandId]
INNER JOIN [AssetType] AS [a0] ON [m].[AssetTypeId] = [a0].[AssetTypeId]
INNER JOIN [Category] AS [c] ON [m].[CategoryId] = [c].[CategoryId]
WHERE [m].[BrandId] = 2618

What should I do to make the EF Core could generate a parameterized SQL query from the returned IQueryable to avoid the SQL injection risk.

I tried many things to achieve this, but without success so far

0

There are 0 best solutions below