为什么需要一套可扩展的查询条件体系
在业务系统中,动态构建 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 组合模式 方案,看它如何优雅地解决上述问题。
整体架构:三个层次
整个体系可以分成三层:
- 运算符层(FilterOperator):把包含、等于、LIKE 开头这些语义封装成独立的策略对象,每个对象知道自己如何生成 SQL 片段和参数值。
- 条件树层(IFilterNode / FilterCondition / FilterGroup):用组合模式组织条件,单个条件是叶子节点,AND/OR 分组是分支节点,最终形成一棵树。
- 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 内部做了这些事:
- 递归遍历条件树,每个 FilterCondition 通过 GetFilterOperator 找到策略对象
- 通过 MetadataCache.GetTableMetadata 获取属性到列名的映射
- Contains 运算符的 BuildParameterValue 把 admin 转成带通配符的 “%admin%”(同时转义特殊字符)
- 参数化方式把值塞进 command.Parameters,避免 SQL 注入
- 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 或查询构造器还在到处拼字符串,不妨参考一下这套设计。