C# 表达式树实战:从零构建动态 LINQ Where 解析器

在日常开发中,我们经常会遇到这样的需求:前端表格需要根据多列条件动态筛选数据,后端则要根据这些条件拼接出对应的 SQL WHERE 子句。最直觉的做法是拼接字符串,但随之而来的 SQL 注入、类型转换、括号优先级问题都让人头疼。

本文结合 SwitchData 项目中的 ExpressionParser 实现,介绍如何用 C# 表达式树(Expression Trees) 把强类型的 LINQ 表达式 x => x.Name.Contains("A") && x.Age > 18 解析成结构化查询条件,从而安全、可维护地生成 SQL。

一、为什么需要表达式树解析

表达式树并不是新东西,但它能把 “代码即数据” 的理念发挥到极致。普通委托 Func<T, bool> 只能执行,而 Expression<Func<T, bool>> 可以被遍历、分析和转换。

在数据查询层,我们希望做到:

  1. 强类型:编译期就能发现字段名拼写错误。
  2. 可解析:把 Where 表达式拆成列名、运算符、值、逻辑关系等结构化信息。
  3. 可组合:多个条件能自由 AND / OR 组合,并正确处理括号优先级。
  4. 防注入:使用参数化查询,而不是字符串拼接。

下面先看一个完整的解析示例,再拆解实现细节。

二、整体流程

flowchart LR
    A[前端查询条件] --> B[构建 Expression<Func<T, bool>>]
    B --> C[ExpressionParser 遍历表达式树]
    C --> D[生成 QueryCondition 列表]
    D --> E[转换为 SQL WHERE 子句]
    E --> F[参数化查询执行]

核心类职责如下:

  • PredicateBuilder:把多个独立条件组合成一个表达式。
  • ConditionVisitor:继承 ExpressionVisitor,遍历表达式树并收集条件。
  • MetadataCache:缓存实体与数据库字段的映射关系,用于把属性名转成列名。
  • FilterOperator:把解析后的条件格式化成具体 SQL 片段。

三、用 PredicateBuilder 组合条件

当条件来自前端多个筛选框时,我们通常会得到多个零散的条件表达式。PredicateBuilder 提供 AndOr 扩展方法,把多个 Expression<Func<T, bool>> 合并成一个。

public static class PredicateBuilder
{
    public static Expression<Func<T, bool>> True<T>() => f => true;
    public static Expression<Func<T, bool>> False<T>() => f => false;

    public static Expression<Func<T, bool>> And<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        var parameter = left.Parameters[0];
        var rightBody = ReplaceParameter(right.Body, right.Parameters[0], parameter);
        return Expression.Lambda<Func<T, bool>>(
            Expression.AndAlso(left.Body, rightBody), parameter);
    }

    public static Expression<Func<T, bool>> Or<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        var parameter = left.Parameters[0];
        var rightBody = ReplaceParameter(right.Body, right.Parameters[0], parameter);
        return Expression.Lambda<Func<T, bool>>(
            Expression.OrElse(left.Body, rightBody), parameter);
    }
}

关键点在于 参数统一。每个表达式都有自己的参数 f,合并时必须把右侧表达式的参数替换成左侧的参数,否则 Expression.AndAlso 会抛出异常。

四、遍历表达式树:ConditionVisitor

ExpressionVisitor 是 .NET 提供的表达式树遍历基类。我们只需重写几个关键方法,就能把表达式转换成自定义的 QueryCondition

4.1 二元表达式:比较与逻辑运算

protected override Expression VisitBinary(BinaryExpression node)
{
    // 处理 && 和 ||
    if (node.NodeType == ExpressionType.AndAlso ||
        node.NodeType == ExpressionType.OrElse)
    {
        var relation = node.NodeType == ExpressionType.AndAlso
            ? RelationType.AND : RelationType.OR;

        Visit(node.Left);
        _pendingRelation = relation;
        Visit(node.Right);
        return node;
    }

    // 处理 ==、!=、>、<、>=、<=
    if (IsComparison(node.NodeType))
    {
        var left = StripConvert(node.Left);
        var right = StripConvert(node.Right);

        if (TryExtractMemberAndConstant(left, right, out var member, out var value, out var memberOnLeft))
        {
            Conditions.Add(new QueryCondition
            {
                Column = GetColumnName(member),
                Value = value,
                Operator = memberOnLeft
                    ? GetOperator(node.NodeType, value)
                    : GetReversedOperator(node.NodeType, value),
                Relation = GetEffectiveRelation()
            });
        }
        return node;
    }

    return base.VisitBinary(node);
}

这里有两个细节:

  1. 剥除 Convert 节点:C# 编译器在值类型与可空类型比较时,会自动生成 Convert(x.Age) 节点,需要递归剥离。
  2. 支持常量左右互换:比如 18 < x.Agex.Age > 18 应该解析出同样的条件。

4.2 方法调用:字符串模糊查询

x.Name.Contains("A")StartsWithEndsWith 属于 MethodCallExpression,需要单独处理:

protected override Expression VisitMethodCall(MethodCallExpression node)
{
    if (node.Method.DeclaringType == typeof(string) &&
        node.Object is MemberExpression member &&
        node.Arguments.Count == 1)
    {
        var op = node.Method.Name switch
        {
            "Contains" => OperatorType.Contains,
            "StartsWith" => OperatorType.StartsWith,
            "EndsWith" => OperatorType.EndsWith,
            _ => (OperatorType?)null
        };

        if (op.HasValue)
        {
            Conditions.Add(new QueryCondition
            {
                Column = GetColumnName(member),
                Operator = op.Value,
                Value = GetConstantValue(node.Arguments[0]),
                Relation = GetEffectiveRelation()
            });
            return node;
        }
    }
    return base.VisitMethodCall(node);
}

4.3 成员访问:布尔属性简写

x.IsActive 等价于 x.IsActive == trueExpressionVisitor 可以直接识别:

protected override Expression VisitMember(MemberExpression node)
{
    if ((node.Type == typeof(bool) || node.Type == typeof(bool?)) &&
        node.Expression is ParameterExpression)
    {
        Conditions.Add(new QueryCondition
        {
            Column = GetColumnName(node),
            Operator = OperatorType.Equals,
            Value = true,
            Relation = GetEffectiveRelation()
        });
        return node;
    }
    return base.VisitMember(node);
}

五、提取常量值与闭包变量

表达式中的值可能是字面常量,也可能是闭包捕获的外部变量。GetConstantValue 方法要同时处理这两种情况:

private static object GetConstantValue(Expression expr)
{
    expr = StripConvert(expr);

    if (expr is ConstantExpression c) return c.Value;

    if (expr is MemberExpression m)
    {
        // 闭包变量被编译器生成到匿名类字段中
        if (m.Expression is ConstantExpression container)
        {
            var obj = container.Value;
            if (m.Member is FieldInfo f) return f.GetValue(obj);
            if (m.Member is PropertyInfo p) return p.GetValue(obj);
        }

        // 静态字段/属性
        if (m.Expression == null)
        {
            if (m.Member is FieldInfo sf) return sf.GetValue(null);
            if (m.Member is PropertyInfo sp) return sp.GetValue(null);
        }
    }

    // 兜底:编译执行
    return Expression.Lambda(expr).Compile().DynamicInvoke();
}

六、处理括号与分组

&&|| 混合时,必须正确处理括号。实现思路是:当子表达式中的逻辑运算符与父级不一致时,为其分配一个新的 GroupId

private static bool NeedsGroup(Expression expr, ExpressionType parentType)
{
    if (expr is BinaryExpression binary &&
        (binary.NodeType == ExpressionType.AndAlso ||
         binary.NodeType == ExpressionType.OrElse))
    {
        return binary.NodeType != parentType;
    }
    return false;
}

例如 (A && B) || C 中,A && B 会被标记为新分组,生成的 SQL 才能正确写成 (A AND B) OR C

七、从属性名到数据库列名

表达式里只有属性名 x.Name,但 SQL 里需要列名 [UserName]MetadataCache 通过 DbColumnAttribute 建立映射,并在 ExpressionParser.GetColumnName 中解析:

private static string GetColumnName(MemberExpression member)
{
    var path = GetPropertyPath(member); // 例如 "Customer.Name"
    var meta = MetadataCache.GetTableMetadata(declaringType);
    if (meta.PropertyMap.TryGetValue(path, out var col))
        return col.ColumnName;

    // 降级:属性名与列名一致
    return member.Member.Name;
}

八、使用示例

Expression<Func<User, bool>> predicate = u =>
    u.Name.Contains("张") && (u.Age > 18 || u.IsActive);

var conditions = ExpressionParser.ParseConditions(predicate);
// 生成类似:Name LIKE '%张%' AND (Age > 18 OR IsActive = 1)

九、总结

表达式树让 “把代码当数据解析” 成为可能。通过 PredicateBuilder 组合条件、ExpressionVisitor 遍历表达式、元数据缓存映射字段,我们可以:

  • 用强类型 LINQ 表达式替代字符串拼接。
  • 自动处理常量、闭包、可空类型、布尔简写等边界情况。
  • 生成带括号的结构化条件,最终输出安全的参数化 SQL。

SwitchData 的查询层,这套机制支撑了前端复杂筛选与后端动态 SQL 的无缝衔接。如果你的项目也在为动态查询苦恼,不妨试试表达式树这套方案。