为什么需要一套可扩展的查询条件体系

在业务系统中,动态构建 SQL WHERE 子句是一个绕不开的需求。无论是多条件组合查询、高级搜索面板,还是权限过滤,本质上都是把用户传入的条件翻译成合法的 SQL 语句。

一个常见的做法是直接拼字符串:

string sql = "SELECT * FROM Users WHERE 1=1";
if (!string.IsNullOrEmpty(name)) sql += " AND Name LIKE '%" + name + "%'";
if (age > 0) sql += " AND Age > " + age;

这段代码能跑,但问题显而易见:SQL 注入风险逻辑与字段耦合扩展新运算符需要改一堆地方复杂的 AND/OR 嵌套完全不可维护

本文以一个真实项目(SwitchData)中的实现为例,剖析一套完整的 FilterOperator 策略模式 + IFilterNode 组合模式 方案,看它如何优雅地解决上述问题。


整体架构:三个层次

整个体系可以分成三层:

  1. 运算符层(FilterOperator):把包含、等于、LIKE 开头这些语义封装成独立的策略对象,每个对象知道自己如何生成 SQL 片段和参数值。
  2. 条件树层(IFilterNode / FilterCondition / FilterGroup):用组合模式组织条件,单个条件是叶子节点,AND/OR 分组是分支节点,最终形成一棵树。
  3. SQL 构建层(Database.BuildFilterNode):递归遍历条件树,根据元数据把属性名映射成数据库列名,生成参数化 SQL。
graph TD
    A[IFilterNode] --> B[FilterCondition 叶子节点]
    A --> C[FilterGroup 分支节点]
    C --> D[LogicalOperator.AND]
    C --> E[LogicalOperator.OR]
    B --> F[FilterOperator 策略对象]
    F --> G[ConditionFormat]
    F --> H[ValueFormat]
    F --> I[RequiresParameter / RequiresEscape]
    J[Database.BuildFilterNode] --> A
    J --> K[MetadataCache 属性到列名映射]
    J --> L[SqlCommand.Parameters 参数化查询]

第一层:运算符策略(FilterOperator)

运算符类型枚举

所有支持的查询运算符被定义成一个枚举:

public enum FilterOperatorType
{
    [Description("包含")]         Contains,
    [Description("不包含")]       NotContains,
    [Description("空值")]         IsNullOrEmpty,
    [Description("非空值")]       IsNotNullOrEmpty,
    [Description("开始于")]       StartsWith,
    [Description("结束于")]       EndsWith,
    [Description("等于")]         Equal,
    [Description("不等于")]       NotEqual,
    [Description("大于")]         GreaterThan,
    [Description("大于等于")]     GreaterThanOrEqual,
    [Description("小于")]         LessThan,
    [Description("小于等于")]     LessThanOrEqual,
    [Description("长度大于")]     LengthGreaterThan,
    [Description("长度等于")]     LengthEqual,
    [Description("长度小于")]     LengthLessThan
}

策略对象本体

每个运算符不是一个 if-else 分支,而是一个独立的 FilterOperator 实例,它持有几个关键信息:

public sealed class FilterOperator
{
    public FilterOperatorType OperatorType { get; }   // 运算符类型
    public string DisplayName { get; }                // 显示名称
    public string ConditionFormat { get; }            // SQL 条件格式化模板
    public string ValueFormat { get; }                // 参数值格式化模板
    public bool AllowEmptyValue { get; }              // 是否允许空值
    public bool RequiresEscape { get; }              // 是否需要 LIKE 特殊字符转义
    public bool RequiresParameter { get; }            // 是否需要参数化查询
}

两个核心方法分别负责生成 SQL 片段和参数值:

public string BuildCondition(string columnName, string parameterName)
{
    return string.Format(ConditionFormat, columnName, parameterName);
}

public object BuildParameterValue(object value)
{
    if (value is string strValue)
    {
        if (RequiresEscape)
            strValue = EscapeLikeValue(strValue);
        return string.Format(ValueFormat, strValue);
    }
    return value;
}

运算符注册表

在 Database 基类中,所有运算符实例被一次性注册到一个字典里:

private static readonly IReadOnlyDictionary<FilterOperatorType, FilterOperator> _filterOperators 
    = new Dictionary<FilterOperatorType, FilterOperator>()
{
    [FilterOperatorType.Contains] = new(
        FilterOperatorType.Contains,
        "([{0}] LIKE {1})",     // ConditionFormat: {0}=列名, {1}=参数名
        "%{0}%",                 // ValueFormat: 前后加通配符
        false, true, true),      // AllowEmptyValue=false, RequiresEscape=true, RequiresParameter=true

    [FilterOperatorType.StartsWith] = new(
        FilterOperatorType.StartsWith,
        "([{0}] LIKE {1})",
        "{0}%",                  // ValueFormat: 只在尾部加通配符
        false, true, true),

    [FilterOperatorType.IsNullOrEmpty] = new(
        FilterOperatorType.IsNullOrEmpty,
        "([{0}] IS NULL OR LEN([{0}]) = 0)",  // 不需要参数
        "",
        true, false, false),     // AllowEmptyValue=true, RequiresParameter=false

    [FilterOperatorType.Equal] = new(
        FilterOperatorType.Equal,
        "([{0}] = {1})",
        "{0}",                   // 直接用原值
        false, false, true),
};

注意 NotEqual 的精妙之处:它的 ConditionFormat 是 "([{0}] IS NULL OR [{0}] <> {1})",而不是简单的 "<> {1}"。因为在 SQL Server 里,NULL <> '某值' 的结果是 UNKNOWN 而非 TRUE,直接用 <> 会漏掉 NULL 记录。这个细节体现了策略模式的优势——每个运算符自己处理边界情况,调用者完全不需要关心。

LIKE 特殊字符转义

对于 LIKE 运算符,值中的 [%_ 会被 SQL Server 当作通配符,必须转义:

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

这就避免了用户输入 100%admin_test 时查询结果异常的问题。


第二层:组合模式构建条件树

单个条件无法表达嵌套逻辑,比如”Name 包含 admin 或 Age > 18,同时 Status = 1”。组合模式完美解决了这个问题。

接口与节点类型

public interface IFilterNode { }  // 组件接口

public sealed class FilterCondition : IFilterNode  // 叶子节点
{
    public string PropertyName { get; }           // 实体属性名(不是列名!)
    public FilterOperatorType OperatorType { get; }
    public object Value { get; }
}

public sealed class FilterGroup : IFilterNode  // 分支节点
{
    public LogicalOperator LogicalOperator { get; }   // AND / OR
    public IList<IFilterNode> Children { get; }      // 可以包含 FilterCondition 或嵌套 FilterGroup
}

使用示例

假设实体类 User 有属性 Name、Age、Status:

IFilterNode filter = new FilterGroup(LogicalOperator.And,
    new FilterGroup(LogicalOperator.Or,
        new FilterCondition(nameof(User.Name), FilterOperatorType.Contains, "admin"),
        new FilterCondition(nameof(User.Age), FilterOperatorType.GreaterThan, 18)
    ),
    new FilterCondition(nameof(User.Status), FilterOperatorType.Equal, 1)
);

这个对象结构是一棵树:

FilterGroup(AND)
  ├── FilterGroup(OR)
  │     ├── FilterCondition(Name, Contains, "admin")
  │     └── FilterCondition(Age, GreaterThan, 18)
  └── FilterCondition(Status, Equal, 1)

最终生成的 SQL:

WHERE (([Name] LIKE @p0) OR ([Age] > @p1)) AND ([Status] = @p2)

参数 @p0='%admin%'@p1=18@p2=1


第三层:递归构建 SQL

有了条件树和运算符策略,现在需要把它们翻译成 SQL。核心入口是 Database.BuildWhereClause

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}";
}

递归分发

BuildFilterNode 根据节点类型分发处理——这就是组合模式的统一处理接口:

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}");
    }
}

叶子节点处理(FilterCondition)

BuildFilterCondition 做了五件事:获取运算符策略 → 空值检查 → 属性名到列名映射 → 生成参数名 → 参数化 SQL 生成:

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

    // 1. 获取运算符策略
    var filterOperator = GetFilterOperator(filter.OperatorType);

    // 2. 空值检查(有些运算符允许空值,有些直接跳过)
    if (!filterOperator.AllowEmptyValue && filter.Value.IsNullOrEmptyValue())
        return string.Empty;

    // 3. 属性名 → 数据库列名(通过 MetadataCache)
    var metadata = MetadataCache.GetTableMetadata<T>();
    if (!metadata.PropertyMap.TryGetValue(filter.PropertyName, out var column))
        throw new InvalidOperationException($"实体 {typeof(T).FullName} 不存在属性 {filter.PropertyName}");

    // 4. 生成参数名和 SQL 条件片段
    string parameterName = BuildParameterName($"W_P{parameterIndex}");
    nextParameterIndex++;
    string condition = filterOperator.BuildCondition(column.ColumnName, parameterName);

    // 5. 参数化:有些运算符不需要参数(如 IsNullOrEmpty)
    if (filterOperator.RequiresParameter)
    {
        object value = filterOperator.BuildParameterValue(filter.Value);
        AddInParameter(command, parameterName, column.DbType, value);
    }

    return condition;
}

分支节点处理(FilterGroup)

BuildFilterGroup 递归遍历所有子节点,用 AND/OR 连接:

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)})";
}

注意这里做了空条件过滤——如果某个子条件因为空值检查被跳过(返回空字符串),它不会出现在结果里,也不会产生多余的 AND/OR。


完整调用链示例

把三层串起来,一个完整的查询流程是这样的:

// 1. 构建条件树
var filter = new FilterGroup(LogicalOperator.And,
    new FilterCondition(nameof(User.Name), FilterOperatorType.Contains, "admin"),
    new FilterGroup(LogicalOperator.Or,
        new FilterCondition(nameof(User.Status), FilterOperatorType.Equal, 1),
        new FilterCondition(nameof(User.Status), FilterOperatorType.IsNotNullOrEmpty, null)
    )
);

// 2. 构建 SQL 命令
using var command = database.GetSqlStringCommand("SELECT * FROM Users");
string whereClause = database.BuildWhereClause<User>(command, filter);
command.CommandText += whereClause;

// 3. 执行查询
using var reader = database.ExecuteReader(command);

BuildWhereClause 内部做了这些事:

  1. 递归遍历条件树,每个 FilterCondition 通过 GetFilterOperator 找到策略对象
  2. 通过 MetadataCache.GetTableMetadata 获取属性到列名的映射
  3. Contains 运算符的 BuildParameterValue 把 admin 转成带通配符的 “%admin%”(同时转义特殊字符)
  4. 参数化方式把值塞进 command.Parameters,避免 SQL 注入
  5. FilterGroup 负责用括号和 AND/OR 连接所有子条件

最终生成的 SQL:

SELECT * FROM Users
WHERE ([User_Name] LIKE @W_P0) AND (([Status] = @W_P1) OR ([Status] IS NOT NULL AND LEN([Status]) > 0))

扩展点与工程价值

扩展新运算符

需要一个 IN 运算符?只需在字典里加一项:

[FilterOperatorType.In] = new(
    FilterOperatorType.In,
    "([{0}] IN {1})",
    "(N'{0}', N'{1}', N'{2}')",
    false, false, false),

不用改任何调用方的代码——这就是策略模式的威力。

不同数据库的差异化

Database 基类里的 FilterOperators 属性被标记为 virtual,子类可以覆盖。比如 MySQL 的 NOT LIKE 不需要 IS NULL OR 的包裹:

public class MySqlDatabase : SqlDatabase
{
    protected override IReadOnlyDictionary<FilterOperatorType, FilterOperator> FilterOperators 
        => new Dictionary<FilterOperatorType, FilterOperator>(_filterOperators)
    {
        [FilterOperatorType.NotContains] = new(
            FilterOperatorType.NotContains,
            "([{0}] NOT LIKE {1})",
            "%{0}%", false, true, true),
    };
}

与 MetadataCache 的配合

整个体系依赖 MetadataCache 做属性名到列名的映射。调用者永远不需要写数据库列名,只操作 C# 属性名,这就形成了一个弱 ORM 的闭环:

  • 实体类用特性标注 DbTable 和 DbColumn
  • MetadataCache 在首次访问时反射构建映射并缓存
  • BuildFilterCondition 通过 metadata.PropertyMap 查表得到列名

这个设计让实体类和数据库列的解耦变得极其自然。

参数索引的设计

注意 BuildFilterNode 的 parameterIndex 通过 out 返回下一个索引,确保每个参数的序号全局递增,不会因为条件树的嵌套而冲突。


设计模式总结

整个查询条件体系综合运用了四种设计模式:

模式 体现
策略模式 FilterOperator 封装不同运算符的 SQL 生成逻辑,通过枚举字典注册
组合模式 IFilterNode + FilterCondition + FilterGroup 构成条件树,统一递归处理
工厂模式 Database 基类通过 GetFilterOperator 和 MetadataCache 创建依赖
模板方法 BuildFilterNode 是骨架,BuildFilterCondition / BuildFilterGroup 是可变步骤

踩过的坑

NotEqual 的 NULL 陷阱

最早的版本里 NotEqual 的 ConditionFormat 是简单的 "([{0}] <> {1})"。用户反映排除 Status=0 的记录时,Status 为 NULL 的记录也消失了。排查后发现是 SQL 的 NULL 三值逻辑导致的,改成 "([{0}] IS NULL OR [{0}] <> {1})" 才正确。

LIKE 特殊字符

用户搜索 100% 时,SQL Server 把 % 当通配符匹配了所有记录。加了 EscapeLikeValue 后才解决。

空条件泄漏

如果 AllowEmptyValue=false 且传入空值,BuildFilterCondition 返回空字符串,但早期的 BuildFilterGroup 没做空字符串过滤,导致生成了 AND AND ([...]) 这种无效 SQL。

DbCommand 生命周期

FilterOperator 的参数是通过 AddInParameter 加进传入的 DbCommand 里的。如果调用者创建了命令但忘记添加到正确的连接或事务,参数就变成了游离的。这个体系本身不做生命周期管理,把选择权交给调用者——这是合理的,因为同一个命令可能被复用多次。


结语

一套好的查询条件体系不只是”把条件拼成 SQL”这么简单。它需要同时解决:

  • 运算符的多样化扩展(策略模式)
  • 条件的组合嵌套(组合模式 + 递归)
  • 属性名与列名的解耦(元数据缓存)
  • SQL 注入的安全防护(参数化查询)
  • 数据库方言的差异化(模板方法 + virtual 属性)

FilterOperator + IFilterNode 这套方案在 SwitchData 项目中已经稳定运行,支撑了设备管理、表数据查询、UPF 地址规划等多个模块的动态查询需求。如果你自己写的 ORM 或查询构造器还在到处拼字符串,不妨参考一下这套设计。