优雅地构建动态、复杂且安全的 SQL 查询
在现代应用程序开发中,我们经常需要根据用户输入或复杂的业务规则来动态构建 SQL 查询。一个常见挑战是如何优雅且安全地处理这些多变的查询条件。简单地拼接字符串不仅容易出错,还会带来严重的安全漏洞,如 SQL 注入。
本文将以一个设计精良的 SearchHelper.cs
工具类为例,深入探讨一种在 C# 中构建动态、可嵌套的复杂查询条件的强大方法。我们将学习如何设计可复用的查询模型、如何通过递归生成 SQL WHERE
子句,以及如何确保查询的安全性。
1. 问题的根源:为什么简单的字符串拼接是危险的?
让我们从一个反面教材开始。假设我们需要根据用户输入的姓名和年龄进行查询,很多人可能会这样写:
csharp
string name = userNameInput.Text;
string age = ageInput.Text;
string sql = "SELECT * FROM Users WHERE 1=1";
if (!string.IsNullOrEmpty(name))
{
sql += " AND Name = '" + name + "'";
}
if (!string.IsNullOrEmpty(age))
{
sql += " AND Age > " + age;
}
// 执行 SQL...
这种方式存在两个致命问题:
- SQL 注入风险 : 如果用户在
name
输入框中输入'; DROP TABLE Users;--
,拼接后的 SQL 就会变成一场灾难。 - 代码难以维护 : 随着查询条件越来越复杂(例如,需要支持
OR
、IN
、BETWEEN
等),if-else
逻辑会迅速膨胀,变得混乱不堪。
2. 核心设计:将查询条件模型化
要优雅地解决这个问题,第一步是将查询条件抽象成一个数据模型。让我们定义一个 ComplexQuery
类,它可以像树一样表示复杂的逻辑关系。
csharp
public class ComplexQuery
{
/// <summary>
/// 逻辑操作符 (AND / OR)
/// </summary>
public string condition { get; set; } = "AND";
/// <summary>
/// 嵌套的规则列表
/// </summary>
public List<ComplexQuery> rules { get; set; } = new List<ComplexQuery>();
/// <summary>
/// 查询字段
/// </summary>
public string field { get; set; }
/// <summary>
/// 查询操作符 (如 equal, contains, in 等)
/// </summary>
public string @operator { get; set; }
/// <summary>
/// 查询值
/// </summary>
public List<string> values { get; set; } = new List<string>();
/// <summary>
/// 字段类型 (如 datetime)
/// </summary>
public string @type { get; set; }
}
这个设计的精妙之处在于 rules
属性,它允许一个 ComplexQuery
对象包含多个子 ComplexQuery
对象,从而无限嵌套,形成复杂的查询树。
例如,要表示 (Age > 30 AND (Department = 'Sales' OR Department = 'Marketing'))
,我们可以这样构建对象:
csharp
var query = new ComplexQuery
{
condition = "AND",
rules = new List<ComplexQuery>
{
new ComplexQuery { field = "Age", @operator = "greater", values = new List<string> { "30" } },
new ComplexQuery
{
condition = "OR",
rules = new List<ComplexQuery>
{
new ComplexQuery { field = "Department", @operator = "equal", values = new List<string> { "Sales" } },
new ComplexQuery { field = "Department", @operator = "equal", values = new List<string> { "Marketing" } }
}
}
}
};
3. 递归生成 SQL WHERE 子句
有了查询模型,我们就可以编写一个递归函数来解析这个树状结构,并生成对应的 SQL WHERE
子句。这正是 SearchHelper.cs
中 SearchInfoToWhere
方法的核心思想。
csharp
public static class SearchHelper
{
// ... 操作符常量定义 ...
public const string EQUAL = "equal";
public const string CONTAINS = "contains";
// ... 其他操作符
public static string SearchInfoToWhere<T>(ComplexQuery complexQuery, ref List<object> parameters)
{
if (complexQuery == null || (complexQuery.rules == null || complexQuery.rules.Count == 0) && string.IsNullOrEmpty(complexQuery.field))
{
return "";
}
// 如果包含子规则,则递归处理
if (complexQuery.rules != null && complexQuery.rules.Count > 0)
{
var subClauses = complexQuery.rules
.Select(rule => SearchInfoToWhere<T>(rule, ref parameters))
.Where(clause => !string.IsNullOrEmpty(clause))
.ToList();
if (subClauses.Count == 0)
{
return "";
}
return $"({string.Join($" {complexQuery.condition} ", subClauses)})";
}
// 否则,处理单个规则
else
{
// 根据 operator 生成单个条件语句
// 使用参数化查询来防止 SQL 注入
var paramName = $"@{parameters.Count}";
parameters.Add(complexQuery.values.FirstOrDefault()); // 简化处理,实际应更复杂
switch (complexQuery.@operator)
{
case EQUAL:
return $"{complexQuery.field} = {paramName}";
case CONTAINS:
parameters[parameters.Count - 1] = $"%{complexQuery.values.FirstOrDefault()}%";
return $"{complexQuery.field} LIKE {paramName}";
case IN:
// IN 的处理需要生成多个参数
var inParams = new List<string>();
foreach(var val in complexQuery.values)
{
var pName = $"@{parameters.Count}";
parameters.Add(val);
inParams.Add(pName);
}
return $"{complexQuery.field} IN ({string.Join(", ", inParams)})";
// ... 其他 case ...
default:
return "";
}
}
}
}
注意:上面的代码是根据 SearchHelper.cs
核心思想简化的示例,用于说明原理。
关键点:
- 递归调用 : 当一个
ComplexQuery
节点包含rules
时,函数会遍历这些rules
并对每个rule
进行递归调用。 - 参数化查询 : 这是安全性的基石。不直接将值拼接到 SQL 语句中,而是使用占位符(如
@0
,@1
),然后将实际值添加到一个单独的参数列表中。这从根本上杜绝了 SQL 注入的可能。 - 操作符处理 :
switch
语句根据不同的@operator
生成不同的 SQL 语法,例如LIKE
用于CONTAINS
,IN (...)
用于IN
操作。
4. 实践应用
现在,可以轻松地使用它来构建查询了。
csharp
// 1. 构建复杂的查询模型
var queryModel = new ComplexQuery { /* ... 如上文示例 ... */ };
// 2. 初始化参数列表
var parameters = new List<object>();
// 3. 生成 WHERE 子句
string whereClause = SearchHelper.SearchInfoToWhere<User>(queryModel, ref parameters);
// 4. 构建最终 SQL
string finalSql = $"SELECT * FROM Users WHERE {whereClause}";
// 5. 使用 Dapper、EF Core 或 ADO.NET 执行查询
// 例如,使用 Dapper:
// var users = connection.Query<User>(finalSql, new DynamicParameters(parameters));
5. 进阶应用:支持用户自定义表单的动态查询
这套设计模式最强大的应用之一,便是为用户自定义表单(或称动态表单、低代码平台数据源)提供后端查询能力。在这类场景下,数据表的字段是动态变化的,我们无法在编译时为每个表都创建一个对应的 C# 实体类。
解决方案的核心在于:前端负责根据用户的筛选条件构建 ComplexQuery
对象的 JSON 结构,而后端则通用地解析这个结构来生成 SQL。由于 field
本身就是字符串,后端无需提前知道具体的字段名。SearchHelper.cs
中对 Dictionary<string, object>
的处理能力,正是为此类场景量身打造。
工作流程示例:
-
前端生成查询 JSON:用户在筛选器界面操作后,前端生成如下 JSON 字符串。
json{ "condition": "AND", "rules": [ { "field": "custom_field_01", "operator": "contains", "values": ["some_value"] }, { "field": "user_defined_date", "operator": "greater", "values": ["2023-01-01"], "type": "datetime" } ] }
-
后端通用接口处理
csharp// 1. 从前端接收 JSON string jsonQuery = GetQueryJsonFromRequest(); // 2. 反序列化为 ComplexQuery 对象 var queryModel = JsonConvert.DeserializeObject<ComplexQuery>(jsonQuery); // 3. 初始化参数列表 var parameters = new List<object>(); // 4. 调用一个非泛型或面向 Dictionary 的版本来生成 WHERE 子句 // 这里的 SearchInfoToWhere 不再需要泛型约束 <T>,可以直接处理字符串字段 string whereClause = SearchHelper.SearchInfoToWhere(queryModel, ref parameters); // 5. 构建并执行 SQL string tableName = "UserDefinedTable_123"; // 表名也可以是动态的 string finalSql = $"SELECT * FROM {tableName} WHERE {whereClause}"; // 6. 执行查询...
通过这种方式,后端查询接口变得高度通用,能够为任意结构的自定义表单提供服务,而无需修改一行后端代码,真正实现了数据结构与查询逻辑的解耦。
7. 结论
通过将查询条件模型化并使用递归来解析模型,可以构建一个强大、灵活且安全的动态 SQL 查询生成器。这种方法不仅解决了 SQL 注入的风险,还极大地提高了代码的可读性和可维护性,使开发者能够从容应对各种复杂的查询需求。
SearchHelper.cs
的实现展示了一个优秀的设计模式。其核心思想------将查询条件模型化并利用递归进行解析------是解决动态查询问题的关键。通过理解并应用这种模式,开发者可以在自己的项目中构建出同样优雅且安全的查询解决方案。