一、从一个真实的坑说起

后台管理系统里几乎都有这种查询接口:服务名、设备名、厂商、地市……每一项都是可选条件,传了就过滤,不传就忽略。最常见的写法是字符串拼接:

var sql = "SELECT * FROM NeInfo WHERE 1=1";
if (!string.IsNullOrEmpty(serviceName))
    sql += $" AND ServiceName LIKE '%{serviceName}%'";   // 注入 + LIKE 通配符双坑
if (!string.IsNullOrEmpty(deviceName))
    sql += $" AND DeviceName = '{deviceName}'";

这段代码有三个经典问题:

  1. SQL 注入:值直接拼进 SQL,一个 ' 就能改写语句结构;
  2. LIKE 通配符失控:用户输入 %、_、[ 会改变匹配语义;
  3. NULL 三值逻辑:写 NOT LIKE、<> 时,NULL 行会悄悄消失(后面细讲)。

我们的 ORM(SwitchData.Common 的 Database 层)里有一套更优雅的方案:把查询条件建模成对象树,用组合模式递归生成 WHERE 子句,配合运算符注册表把每个运算符的 SQL 模板、转义规则、是否参数化集中管理。今天就把这套设计完整拆给大家看。

二、整体设计:把 WHERE 子句建模成对象树

WHERE 子句天然是树形结构:叶子节点是「字段 运算符 值」的条件,中间节点是 AND/OR 逻辑组,组里还能嵌套组。这正是组合模式(Composite Pattern)的标准应用场景。

整个设计只有三个类型(源码位于 SwitchData.Common/Db/FilterOperator.cs):

classDiagram
    class IFilterNode {
        <<interface>>
    }
    class FilterCondition {
        +string PropertyName
        +FilterOperatorType OperatorType
        +object Value
    }
    class FilterGroup {
        +LogicalOperator LogicalOperator
        +IList~IFilterNode~ Children
    }
    class FilterOperator {
        +FilterOperatorType OperatorType
        +string ConditionFormat
        +string ValueFormat
        +bool RequiresEscape
        +bool RequiresParameter
        +BuildCondition(column, param) string
        +BuildParameterValue(value) object
    }
    IFilterNode <|.. FilterCondition
    IFilterNode <|.. FilterGroup
    FilterGroup o-- IFilterNode : Children 递归组合
    Database o-- FilterOperator : 运算符注册表
    Database ..> IFilterNode : BuildWhereClause 递归遍历
  • FilterCondition:叶子节点,一个最小粒度的过滤条件;
  • FilterGroup:容器节点,持有若干 IFilterNode 子节点和一个 AND/OR 逻辑运算符——因为子节点类型还是 IFilterNode,所以组里可以无限嵌套组;
  • FilterOperator:不属于树,是运算符的元数据,描述每一类运算符如何翻译成 SQL。

先用 switch 模式匹配递归遍历这棵树,参数编号贯穿整棵树递增,就能边生成 SQL 边往 DbCommand 里塞参数——树的结构和参数化查询天然契合。

三、运算符注册表:一张表管住 17 种运算符

传统做法里,每种运算符是一段 if/else,散落在代码各处。这里改成声明式注册表:每个运算符注册一个 FilterOperator 对象,把翻译规则全部收进元数据(源码位于 SwitchData.Common/Db/Database.cs):

private static readonly IReadOnlyDictionary<FilterOperatorType, FilterOperator> _filterOperators =
    new Dictionary<FilterOperatorType, FilterOperator>()
{
    [FilterOperatorType.Contains]            = new(FilterOperatorType.Contains,            "([{0}] LIKE {1})",                          "%{0}%", false, true,  true),
    [FilterOperatorType.NotContains]         = new(FilterOperatorType.NotContains,         "([{0}] IS NULL OR [{0}] NOT LIKE {1})",     "%{0}%", false, true,  true),
    [FilterOperatorType.IsNullOrEmpty]       = new(FilterOperatorType.IsNullOrEmpty,       "([{0}] IS NULL OR LEN([{0}]) = 0)",          "",      true,  false, false),
    [FilterOperatorType.StartsWith]          = new(FilterOperatorType.StartsWith,          "([{0}] LIKE {1})",                          "{0}%",  false, true,  true),
    [FilterOperatorType.Equal]               = new(FilterOperatorType.Equal,               "([{0}] = {1})",                             "{0}",   false, false, true),
    [FilterOperatorType.NotEqual]            = new(FilterOperatorType.NotEqual,            "([{0}] IS NULL OR [{0}] <> {1})",           "{0}",   false, false, true),
    [FilterOperatorType.GreaterThanOrEqual]  = new(FilterOperatorType.GreaterThanOrEqual,  "([{0}] >= {1})",                            "{0}",   false, false, true),
    // ……共 17 个,含 IsNullOrEmpty / EndsWith / LengthEqual 等
};

构造函数的六个参数依次是:operatorType、conditionFormat(SQL 条件模板)、valueFormat(值的包装模板)、allowEmptyValue(空值是否合法)、requiresEscape(是否需要 LIKE 转义)、requiresParameter(是否走参数化)。

这个设计有两个妙处:

1. SQL 模板与值的模板分离。ConditionFormat 里的 {0} 是列名、{1} 是参数名;ValueFormat 里的 {0} 是用户值。比如 StartsWith 的条件是 [{0}] LIKE {1},而值包装成 {0}%——值怎么变成通配符形态,是运算符自己的事,构建器完全不关心。

2. RequiresParameter 把「无值运算符」也统一了。IsNullOrEmpty 根本没有参数,标记 requiresParameter: false 后,构建器只生成 SQL 片段、不往命令里加参数。一套流程同时覆盖带值和不带值的运算符,不需要特判。

四、递归生成:BuildFilterNode 是整套方案的心脏

真正把对象树翻译成 WHERE 子句的方法不到 100 行,先看总入口(Database.cs):

public virtual string BuildWhereClause<T>(DbCommand command, IFilterNode filter)
{
    if (filter == null)
    {
        return string.Empty;
    }

    string sql = BuildFilterNode<T>(command, filter, 0, out _);

    return string.IsNullOrEmpty(sql) ? string.Empty : $" WHERE {sql}";
}

核心递归用 switch 模式匹配分发两种节点:

private string BuildFilterNode<T>(DbCommand command, IFilterNode node, int parameterIndex, out int nextParameterIndex)
{
    switch (node)
    {
        case FilterCondition condition:
            return BuildFilterCondition<T>(command, condition, parameterIndex, out nextParameterIndex);

        case FilterGroup group:
            return BuildFilterGroup<T>(command, group, parameterIndex, out nextParameterIndex);

        default:
            throw new NotSupportedException($"不支持的过滤节点类型:{node.GetType().FullName}");
    }
}

叶子节点负责「取元数据 → 校验列名 → 生成参数」三步:

private string BuildFilterCondition<T>(DbCommand command, FilterCondition filter, int parameterIndex, out int nextParameterIndex)
{
    nextParameterIndex = parameterIndex;

    var metadata = MetadataCache.GetTableMetadata<T>();

    var filterOperator = GetFilterOperator(filter.OperatorType);

    // 空值条件:允许空值的运算符照常处理,否则整条条件直接跳过
    if (!filterOperator.AllowEmptyValue && filter.Value.IsNullOrEmptyValue())
    {
        return string.Empty;
    }

    // 列名白名单校验:实体上不存在的属性直接抛异常
    if (!metadata.PropertyMap.TryGetValue(filter.PropertyName, out var column))
    {
        throw new InvalidOperationException($"实体 {typeof(T).FullName} 不包含属性 {filter.PropertyName}");
    }

    string parameterName = BuildParameterName($"W_P{parameterIndex}");

    nextParameterIndex++;

    string condition = filterOperator.BuildCondition(column.ColumnName, parameterName);

    if (filterOperator.RequiresParameter)
    {
        object value = filterOperator.BuildParameterValue(filter.Value);

        AddInParameter(command, parameterName, column.DbType, value);
    }

    return condition;
}

容器节点负责拼接子条件:

private string BuildFilterGroup<T>(DbCommand command, FilterGroup group, int parameterIndex, out int nextParameterIndex)
{
    nextParameterIndex = parameterIndex;

    List<string> conditions = [];

    foreach (var child in group.Children)
    {
        string condition = BuildFilterNode<T>(command, child, nextParameterIndex, out nextParameterIndex);

        if (!string.IsNullOrWhiteSpace(condition))
        {
            conditions.Add(condition);
        }
    }

    if (conditions.Count == 0)
    {
        return string.Empty;
    }

    string logicalOperator = $" {group.LogicalOperator.GetDescription()} ";

    return $"({string.Join(logicalOperator, conditions)})";
}

注意 nextParameterIndex 这个 out 参数:它让参数编号在整棵树中单调递增。某个条件因为空值被跳过时编号不前进,后面的条件无缝补位,永远不会出现参数名冲突。这是递归构建参数化 SQL 时最容易踩的坑——很多人用静态计数器,一遇到「条件被跳过」就对不上号。

五、四个容易踩的细节坑

1. LIKE 通配符转义

用户搜索 100% 时,期望匹配字面量 100%,而不是 100 开头的任意串。SQL Server 的 LIKE 转义要用方括号包裹:

public static string EscapeLikeValue(string value)
{
    return value
        .Replace("[", "[[]")
        .Replace("%", "[%]")
        .Replace("_", "[_]");
}

注意顺序:必须先转义 [,否则后面生成的 [%] 会被再转义一次变成 [%%]。RequiresEscape 为 true 的运算符(Contains、StartsWith、EndsWith 等)在 BuildParameterValue 里自动调用它,调用方无感。

2. NULL 与三值逻辑:为什么模板里到处是 IS NULL OR

看 NotEqual 的模板:([{0}] IS NULL OR [{0}] <> {1})。为什么不直接写 [{0}] <> {1}?

因为 SQL 里 NULL <> 5 的结果不是 true 也不是 false,而是 UNKNOWN,WHERE 里 UNKNOWN 等同于 false。也就是说,直接写 <> 会把所有 NULL 行悄悄过滤掉。业务上「状态不等于已完成」通常应该包含「状态还没填」的记录,所以模板主动补上 IS NULL OR。NotContains、CustomNotContains 同理。这是拼 SQL 时代最隐蔽的 bug 来源之一。

3. 列名不能参数化,只能白名单校验

参数化只能保护值,列名是 SQL 语法的一部分,没法变成参数。这里的防线是 MetadataCache.GetTableMetadata<T>() 的 PropertyMap:调用方传入的 PropertyName 必须能在实体 T 上找到对应属性,否则直接抛异常。配合调用侧统一用 nameof(NeInfo.ServiceName) 写法,列名永远来自编译期检查过的属性名,注入无从谈起。

4. 空值条件整体跳过,而不是拼出 = NULL

filter.Value 为空且运算符不允许空值时,返回 string.Empty,构建组节点时会把它过滤掉。这样「没传的条件就当不存在」,不会拼出 ServiceName = NULL 这种永远为 UNKNOWN 的废条件。

六、真实调用效果

看一下业务层的实际用法(SwitchData.Business/NeData/NeInfoService.cs):

string whereClause = database.BuildWhereClause<NeInfo>(
    command,
    new FilterGroup(
        LogicalOperator.And,
        new FilterCondition(nameof(NeInfo.ServiceName), FilterOperatorType.Contains, serviceName),
        new FilterCondition(nameof(NeInfo.DeviceName), FilterOperatorType.Equal, deviceName),
        new FilterCondition(nameof(NeInfo.DeviceType), FilterOperatorType.Equal, deviceType),
        new FilterCondition(nameof(NeInfo.VendorName), FilterOperatorType.Equal, vendorName)));

四个参数都可能为 null。假设只传了 serviceName = "PCF",生成的 SQL 是:

SELECT * FROM [NeInfo]
WHERE ([ServiceName] LIKE @W_P0)
-- @W_P0 = '%PCF%'(值为空的条件全部跳过)

嵌套组合也一样自然——「厂商是华为或中兴,且设备名包含指定关键字」:

new FilterGroup(
    LogicalOperator.And,
    new FilterGroup(
        LogicalOperator.Or,
        new FilterCondition(nameof(NeInfo.VendorName), FilterOperatorType.Equal, "华为"),
        new FilterCondition(nameof(NeInfo.VendorName), FilterOperatorType.Equal, "中兴")),
    new FilterCondition(nameof(NeInfo.DeviceName), FilterOperatorType.Contains, keyword));

树形结构的表达力在这一刻体现出来了:业务逻辑怎么嵌套,条件对象就怎么嵌套。

七、总结

这套动态查询条件构建器的设计可以浓缩成三句话:

  1. 组合模式建模:FilterCondition 叶子 + FilterGroup 容器,WHERE 子句的任意 AND/OR 嵌套都能一一对应,switch 模式匹配 + 递归遍历即可翻译;
  2. 运算符注册表收敛规则:17 种运算符的 SQL 模板、值包装、转义策略、是否参数化全部声明式注册,新增运算符只加一行字典项;
  3. 参数化贯穿全树:out nextParameterIndex 让参数编号跨节点递增,空条件跳过不占号,值全部走 DbParameter,列名走元数据白名单校验,注入路径全堵死。

比字符串拼接安全、比手写 if/else 可组合、比表达式树方案轻量——如果你维护着一个自研轻量 ORM 或者大量手写 SQL 的老系统,这套「对象树 + 注册表 + 参数编号递增」的组合非常值得抄回去。唯一的取舍是它面向 SQL Server 方言(LEN、[列名] 转义),跨库场景可以把注册表做成虚属性,让子类按方言覆盖,Database 基类里预留的 protected virtual FilterOperators 正是为此准备的。