1

so i have a parser that uses use sprache to create an expression from a string. something like PhysicalAddress.State == "GA" has PhysicalAddress.State parsed as the left, == as the operator, and "GA" as the right expression. the parts can then be combined to an expression for use in a queryable like x => (x.PhysicalAddress.State == "GA").

i want to expand it to be able to dynamically handle ienumerables when building expressions like in the test below. i started working on the below for checking if i have an ienumerable (in CreateLeftExprParser surrounded by WIP comment), but i'm stuck on how to get the inner expression for the any. My first thought was to recursively call ParseFiler() to get the inner expression, but that doesn't really fly since it needs the raw string to go on. maybe i could amke an overload that takes the comparison and right tokens?

regardless, open to thoughts and ideas. this is a tricky one.

    [Fact]
    public void simple_with_operator_for_string()
    {
        var input = """Ingredients.Name != "flour" """;
        var filterExpression = FilterParser.ParseFilter<Recipe>(input);
        filterExpression.ToString().Should()
            .Be(""""x => x.Ingredients.Any(y => (y.Name != \"flour\"))"""");
    }
public static class FilterParser
{
    internal static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitConfiguration? config = null)
    {
        var parameter = Expression.Parameter(typeof(T), "x");
        Expression expr; 
        try
        {
            expr = ExprParser<T>(parameter, config).End().Parse(input);
        }
        catch (ParseException e)
        {
            throw new ParsingException(e);
        }
        return Expression.Lambda<Func<T, bool>>(expr, parameter);
    }

    private static readonly Parser<string> Identifier =
        from first in Parse.Letter.Once()
        from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many()
        select new string(first.Concat(rest).ToArray());
    
    private static Parser<ComparisonOperator> ComparisonOperatorParser
    {
        get
        {
            var parsers = Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text()
                .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.GreaterThanOrEqualOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.LessThanOrEqualOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.GreaterThanOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.LessThanOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.ContainsOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.StartsWithOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.EndsWithOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.NotContainsOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.NotStartsWithOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.NotEndsWithOperator().Operator()).Text())
                .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text())
                .SelectMany(op => Parse.Char('*').Optional(), (op, caseInsensitive) => new { op, caseInsensitive });
                
            var compOperator = parsers
                .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined));
            return compOperator;
        }
    }

    private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression parameter, IQueryKitConfiguration? config)
    {
        var comparisonOperatorParser = ComparisonOperatorParser.Token();
        var rightSideValueParser = RightSideValueParser.Token();

        return CreateLeftExprParser(parameter, config)
            .SelectMany(leftExpr => comparisonOperatorParser, (leftExpr, op) => new { leftExpr, op })
            .SelectMany(temp => rightSideValueParser, (temp, right) => new { temp.leftExpr, temp.op, right })
            .Select(temp =>
            {
                if (temp.leftExpr.NodeType == ExpressionType.Constant && ((ConstantExpression)temp.leftExpr).Value!.Equals(true))
                {
                    return Expression.Equal(Expression.Constant(true), Expression.Constant(true));
                }

                var rightExpr = CreateRightExpr(temp.leftExpr, temp.right);
                return temp.op.GetExpression<T>(temp.leftExpr, rightExpr);
            });
    }

    private static Parser<Expression?>? CreateLeftExprParser(ParameterExpression parameter, IQueryKitConfiguration? config)
    {
        var leftIdentifierParser = Identifier.DelimitedBy(Parse.Char('.')).Token();

        return leftIdentifierParser?.Select(left =>
        {
            // If only a single identifier is present
            var leftList = left.ToList();
            if (leftList.Count == 1)
            {
                var propName = leftList?.First();
                var fullPropPath = config?.GetPropertyPathByQueryName(propName) ?? propName;
                var propNames = fullPropPath?.Split('.');

                var propertyExpression = propNames?.Aggregate((Expression)parameter, (expr, pn) =>
                {
                    var propertyInfo = GetPropertyInfo(expr.Type, pn);
                    var actualPropertyName = propertyInfo?.Name ?? pn;
                    try
                    {
                        return Expression.PropertyOrField(expr, actualPropertyName);
                    }
                    catch(ArgumentException)
                    {
                        throw new UnknownFilterPropertyException(actualPropertyName);
                    }
                });

                var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath);
                if (propertyConfig != null && !propertyConfig.CanFilter)
                {
                    return Expression.Constant(true, typeof(bool));
                }

                return propertyExpression;
            }

            // If the property is nested with a dot ('.') separator
            var nestedPropertyExpression = leftList.Aggregate((Expression)parameter, (expr, propName) =>
            {
                var propertyInfo = GetPropertyInfo(expr.Type, propName);

                // ------- WIP for IEnumerable Handling
                if (propertyInfo != null && typeof(IEnumerable).IsAssignableFrom(propertyInfo.PropertyType) && propertyInfo.PropertyType != typeof(string))
                {
                    // Create a parameter for the lambda in the Any method
                    var anyParameter = Expression.Parameter(propertyInfo.PropertyType.GetGenericArguments().First(), "y");

                    // Get the rest of the property path
                    var subProperties = leftList.Skip(leftList.IndexOf(propName) + 1);

                    // Get the rest of the input for the recursive call
                    var restOfInput = string.Join(".", subProperties);
        
                    var anyLambda = ????

                    // Create the Any method call
                    var anyMethod = typeof(Enumerable).GetMethods().First(m => m.Name == "Any" && m.GetParameters().Length == 2);
                    var genericAnyMethod = anyMethod.MakeGenericMethod(anyParameter.Type);

                    return Expression.Call(genericAnyMethod, expr, anyLambda);
                }
                // --------- End WIP

                var mappedPropertyInfo = config?.PropertyMappings?.GetPropertyInfoByQueryName(propName);
                var actualPropertyName = mappedPropertyInfo?.Name ?? propertyInfo?.Name ?? propName;
                try
                {
                    return Expression.PropertyOrField(expr, actualPropertyName);
                }
                catch(ArgumentException)
                {
                    throw new UnknownFilterPropertyException(actualPropertyName);
                }
            });

            var nestedPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(leftList.Last());
            if (nestedPropertyConfig != null && !nestedPropertyConfig.CanFilter)
            {
                return Expression.Constant(true, typeof(bool));
            }

            return nestedPropertyExpression;
        });
    }

    private static PropertyInfo? GetPropertyInfo(Type type, string propertyName)
    {
        return type.GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
    }

    public static Parser<LogicalOperator> LogicalOperatorParser =>
        from leadingSpaces in Parse.WhiteSpace.Many()
        from op in Parse.String(LogicalOperator.AndOperator.Operator()).Text().Or(Parse.String(LogicalOperator.OrOperator.Operator()).Text())
        from trailingSpaces in Parse.WhiteSpace.Many()
        select LogicalOperator.GetByOperatorString(op);
    
    private static Parser<string> DoubleQuoteParser
        => Parse.Char('"').Then(_ => Parse.AnyChar.Except(Parse.Char('"')).Many().Text().Then(innerValue => Parse.Char('"').Return(innerValue)));


    private static Parser<string> TimeFormatParser => Parse.Regex(@"\d{2}:\d{2}:\d{2}").Text();
    private static Parser<string> DateTimeFormatParser => 
        from dateFormat in Parse.Regex(@"\d{4}-\d{2}-\d{2}").Text()
        from timeFormat in Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse(""))
        from timeZone in Parse.Regex(@"(Z|[+-]\d{2}(:\d{2})?)").Text().Optional().Select(x => x.GetOrElse(""))
        from millis in Parse.Regex(@"\.\d{3}").Text().Optional().Select(x => x.GetOrElse(""))
        select dateFormat + timeFormat + timeZone + millis;

    private static Parser<string> GuidFormatParser => Parse.Regex(@"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}").Text();
    
    private static Parser<string> RawStringLiteralParser =>
        from openingQuotes in Parse.Regex("\"{3,}").Text()
        let count = openingQuotes.Length
        from content in Parse.AnyChar.Except(Parse.Char('"').Repeat(count)).Many().Text()
        from closingQuotes in Parse.Char('"').Repeat(count).Text()
        select content;

    private static Parser<string> RightSideValueParser =>
        from atSign in Parse.Char('@').Optional()
        from leadingSpaces in Parse.WhiteSpace.Many()
        from value in Parse.String("null").Text()
            .Or(GuidFormatParser)
            .XOr(Identifier)
            .XOr(DateTimeFormatParser)
            .XOr(TimeFormatParser)
            .XOr(Parse.Decimal)
            .XOr(Parse.Number)
            .XOr(RawStringLiteralParser.Or(DoubleQuoteParser))
            .XOr(SquareBracketParser) 
        from trailingSpaces in Parse.WhiteSpace.Many()
        select atSign.IsDefined ? "@" + value : value;
    
    private static Parser<string> SquareBracketParser =>
        from openingBracket in Parse.Char('[')
        from content in DoubleQuoteParser
            .Or(GuidFormatParser)
            .Or(Parse.Decimal)
            .Or(Parse.Number)
            .Or(Identifier)
            .DelimitedBy(Parse.Char(',').Token())
        from closingBracket in Parse.Char(']')
        select "[" + string.Join(",", content) + "]";

    private static Parser<Expression> AtomicExprParser<T>(ParameterExpression parameter, IQueryKitConfiguration? config = null)
        => ComparisonExprParser<T>(parameter, config)
            .Or(Parse.Ref(() => ExprParser<T>(parameter, config)).Contained(Parse.Char('('), Parse.Char(')')));

    private static Parser<Expression> ExprParser<T>(ParameterExpression parameter, IQueryKitConfiguration? config = null)
        => OrExprParser<T>(parameter, config);

    private static readonly Dictionary<Type, Func<string, object>> TypeConversionFunctions = new()
    {
        { typeof(string), value => value },
        { typeof(bool), value => bool.Parse(value) },
        { typeof(Guid), value => Guid.Parse(value) },
        { typeof(char), value => char.Parse(value) },
        { typeof(int), value => int.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(float), x => float.Parse(x, CultureInfo.InvariantCulture) },
        { typeof(double), x => double.Parse(x, CultureInfo.InvariantCulture) },
        { typeof(decimal), x => decimal.Parse(x, CultureInfo.InvariantCulture) },
        { typeof(long), value => long.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(short), value => short.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(byte), value => byte.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(DateTime), value => DateTime.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(DateTimeOffset), value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(DateOnly), value => DateOnly.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(TimeOnly), value => TimeOnly.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(TimeSpan), value => TimeSpan.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(uint), value => uint.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(ulong), value => ulong.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(ushort), value => ushort.Parse(value, CultureInfo.InvariantCulture) },
        { typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) },
        // { typeof(Enum), value => Enum.Parse(typeof(T), value) },
    };

    private static Expression CreateRightExpr(Expression leftExpr, string right)
    {
        var targetType = leftExpr.Type;

        targetType = TransformTargetTypeIfNullable(targetType);

        if (TypeConversionFunctions.TryGetValue(targetType, out var conversionFunction))
        {
            if (right == "null")
            {
                return Expression.Constant(null, leftExpr.Type);
            }

            if (right.StartsWith("[") && right.EndsWith("]"))
            {
                var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList();
                var elementType = targetType.IsArray ? targetType.GetElementType() : targetType;
            
                var expressions = values.Select(x =>
                {
                    if (elementType == typeof(string) && x.StartsWith("\"") && x.EndsWith("\""))
                    {
                        x = x.Trim('"');
                    }
            
                    var convertedValue = TypeConversionFunctions[elementType](x);
                    return Expression.Constant(convertedValue, elementType);
                }).ToArray();
            
                var newArrayExpression = Expression.NewArrayInit(elementType, expressions);
                return newArrayExpression;
            }

            if (targetType == typeof(string))
            {
                right = right.Trim('"');
            }
            
            var convertedValue = conversionFunction(right);

            if (targetType == typeof(Guid))
            {
                var guidParseMethod = typeof(Guid).GetMethod("Parse", new[] { typeof(string) });
                return Expression.Call(guidParseMethod, Expression.Constant(convertedValue.ToString(), typeof(string)));
            }

            return Expression.Constant(convertedValue, leftExpr.Type);
        }

        throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'");
    }

    private static Type TransformTargetTypeIfNullable(Type targetType)
    {
        if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            targetType = Nullable.GetUnderlyingType(targetType);
        }

        return targetType;
    }

    private static Parser<Expression> AndExprParser<T>(ParameterExpression parameter, IQueryKitConfiguration? config = null)
        => Parse.ChainOperator(
            LogicalOperatorParser.Where(x => x.Name == LogicalOperator.AndOperator.Operator()),
            AtomicExprParser<T>(parameter, config),
            (op, left, right) => op.GetExpression<T>(left, right)
        );

    private static Parser<Expression> OrExprParser<T>(ParameterExpression parameter, IQueryKitConfiguration? config = null)
        => Parse.ChainOperator(
            LogicalOperatorParser.Where(x => x.Name == LogicalOperator.OrOperator.Operator()),
            AndExprParser<T>(parameter, config),
            (op, left, right) => op.GetExpression<T>(left, right)
        );
}

Paul DeVito
  • 1,542
  • 3
  • 15
  • 38

1 Answers1

1

My first thought would be to rewrite the parser to not parse directly to Expression, but have an intermediary AST. This AST could then be transformed into Expression. This will move the problem of checking for enumerables and constructing the Any-expression to this transform phase where you will have access to children/parents/siblings and so on based on how you structured your tree - and without the constraints of Expression you are free to make a structure that matches the problem, you would like to solve.

If making an intermediary is not an option, then I'd return a stub Any-expression (e.g. Any(y => true)) and then check for such stub in your ComparisonExprParser method - and then build the correct Any-expression in your final Select(temp => ...) where you - as far as I can tell - have all the needed information.

asgerhallas
  • 16,890
  • 6
  • 50
  • 68