一、从一个真实的坑说起
后台管理系统里几乎都有这种查询接口:服务名、设备名、厂商、地市……每一项都是可选条件,传了就过滤,不传就忽略。最常见的写法是字符串拼接:
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}'";
这段代码有三个经典问题:
- SQL 注入:值直接拼进 SQL,一个
'就能改写语句结构; - LIKE 通配符失控:用户输入
%、_、[会改变匹配语义; - 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));
树形结构的表达力在这一刻体现出来了:业务逻辑怎么嵌套,条件对象就怎么嵌套。
七、总结
这套动态查询条件构建器的设计可以浓缩成三句话:
- 组合模式建模:
FilterCondition叶子 +FilterGroup容器,WHERE 子句的任意 AND/OR 嵌套都能一一对应,switch模式匹配 + 递归遍历即可翻译; - 运算符注册表收敛规则:17 种运算符的 SQL 模板、值包装、转义策略、是否参数化全部声明式注册,新增运算符只加一行字典项;
- 参数化贯穿全树:
out nextParameterIndex让参数编号跨节点递增,空条件跳过不占号,值全部走DbParameter,列名走元数据白名单校验,注入路径全堵死。
比字符串拼接安全、比手写 if/else
可组合、比表达式树方案轻量——如果你维护着一个自研轻量 ORM 或者大量手写
SQL 的老系统,这套「对象树 + 注册表 +
参数编号递增」的组合非常值得抄回去。唯一的取舍是它面向 SQL Server
方言(LEN、[列名]
转义),跨库场景可以把注册表做成虚属性,让子类按方言覆盖,Database
基类里预留的 protected virtual FilterOperators
正是为此准备的。