来来来,告诉你一种通过对象构建查询语句的方法
无论使用什么ORM框架,我们最终都是通过SQL语句访问数据库的,只要能够自动构建SQL语句,特别是查询子句,就可以不再需要在代码中直接维护SQL语句。
问题背景
主流ORM框架都支持通过实体对象映射SQL语句中表名和列名,接下来我来介绍一下如何通过另一个对象构建SQL语句中WHERE子句,核心是通过字段构建各类查询条件。
WHERE子句中的查询条件主要包括三类:
- 比较查询条件
- 逻辑查询条件
- 子查询条件
其中,比较查询条件基于数理逻辑中的谓词逻辑(通过大于、小于等比较运算符进行比较运算),逻辑查询条件基于布尔代数(通过AND/OR/NOT进行逻辑运算),子查询条件由于嵌套的是一条完整的SELECT语句还需要基于关系代数。
除了这三类查询条件,ORM框架还需要解决查询条件基于查询参数的赋值进行动态组合的问题,即动态查询问题。这个特性可以基于组合数学中的知识进行解释。
所以,为了支持查询条件的动态构建,ORM框架至少需要满足以下4个特性:
- 构建比较查询条件;
- 构建逻辑查询条件;
- 构建子查询条件;
- 基于查询参数对查询条件进行动态组合。
动态查询问题
对于一个提供n个查询参数的查询接口,用户每次会填写其中的k个查询参数提交一次查询请求,查询接口需要根据这k个查询参数动态得将对应的查询条件拼接为查询子句。
从含有n个元素的集合中选择k个元素构建一个子集,这是组合数学中典型的子集选择问题。
$$ \sum_{k=0}^{n} \binom{n}{k} = 2^n $$ 由公式可知,对于一个提供n个查询参数的查询接口,一共可以构建$2^n$种查询子句。
考虑到软件的维护性和复用性,我们不可能硬编码$2^n$条查询子句,只能通过对查询参数进行判断来确定是否将其对应的查询条件拼接为查询子句。这就是动态查询问题的由来。
传统的解决方案是通过if语句进行构建。每条if语句首先对查询参数进行判断,输出TRUE和FALSE两种判断结果,对应是否执行if块内的查询条件拼接操作,这样正好有$2^n$种拼接结果。
但是这种方案的问题在于需要为每个查询参数编写一段if语句。当查询参数越来越多时,if语句会变得越来越多,从而增加代码的维护难度。
查询对象映射方法
查询对象映射方法是一种根据查询对象的字段赋值将对应的查询条件组合为查询子句的方法。
对于一个定义有n个字段的对象而言,每个字段可以有赋值和未赋值两种状态,n个字段的赋值组合正好有$2^n$种。如果对象中的每个字段都能构建一条查询语句,那么我们就可以利用对象的$2^n$种赋值组合来构建$2^n$种查询子句。
也就是说,基于对象构建查询子句,天然满足特性4。我们将这种用于构建查询子句的对象称为查询对象,将通过查询对象构建查询子句的方法称为查询对象映射方法。
接下来,我们重点关注如何通过字段构建三类查询条件即可。
通过谓词后缀字段映射比较查询条件。比较查询条件通常由列名、比较运算符和参数三部分构成。在DSL(领域特定语言)中,通常会使用谓词短语来表示比较运算符,例如,eq代表等于=、gt代表大于>等等,condition.gt("age", 30)就表示查询条件age > 30。
我们把谓词短语附加在列名后作为字段名称来表示查询条件,例如字段ageGt 就表示查询条件age > ?。类似的后缀还有Eq、Ne、Ge、Lt、Le、In、NotIn、Null、Like等等,这样我们就可以通过字段的后缀来映射不同的比较查询条件。
通过逻辑后缀字段构建逻辑查询条件。逻辑查询条件是由逻辑运算符AND或OR连接的一组查询条件。
逻辑后缀字段的类型为集合或者查询对象,用于构建多个查询条,其中每个元素或字段对应一个查询条件。
逻辑后缀字段的名称中包含逻辑后缀And/Or,用于指定连接多个查询条件的逻辑运算符。
通过子查询字段构建子查询条件。子查询字段的类型需要为查询对象。
例如子查询条件age > (SELECT avg(age) FROM t_user [WHERE]),我们可以将其分为三个部分分别进行映射:
- 对于条件部分age >,我们可以复用谓词后缀字段的映射方法进行构建。但是,为了避免和原有的谓词后缀字段ageGt产生命名冲突,我们需要在谓词后缀后再加一些字符进行区分,例如ageGtAvg。在构建时,忽略谓词后缀后的额外字符;
- 对于子查询的主句部分
SELECT avg(age) FROM t_user:通过注解声明列名和表名,例如@Subquery(select = "avg(age)", from = "t_user"),或者定义在字段名称中,例如ageGtAvgAgeOfUser; - 对于子查询的WHERE子句部分:通过复用查询对象映射方法进行构建。
通过以上三种字段,我们可以完成大部分查询条件的构建。对于其他查询条件,我们可以继续提出新的方法进行支持。
示例
查询对象映射方法仅通过字段的元信息来构建对应的查询条件,因此可以适用于任何面向对象编程语言。我们以Java和Go语言的实现版本进行举例。
Java示例:
public class UserQuery { // WHERE
Integer ageGt; // AND age > ?
Integer ageLe; // AND age <= ?
Boolean memoNull // AND memo IS [NOT] null
String memoLike // AND memo LIKE ?
Boolean valid; // AND valid = ?
UserQuery userOr; // AND (age > ? OR age <= ? OR valid = ?)
@Subquery(select = "avg(age)", from = "t_user")
UserQuery ageGtAvg; // AND age > (SELECT avg(age) FROM t_user [WHERE])
}
Go示例:
type UserQuery struct { // WHERE
PageQuery
AgeGt *int // AND age > ?
AgeLe *int // AND age <= ?
MemoNull *bool // AND memo IS [NOT] null
MemoLike *string // AND memo LIKE ?
Valid *bool // AND valid = ?
UserOr *[]UserQuery // AND (age > ? OR age <= ? OR valid = ?)
// AND age > (SELECT avg(age) FROM t_user [WHERE])
ScoreGtAvg *UserQuery `subquery:"select:avg(age),from:t_user"`
}
其中,每个字段对应一个或一组查询条件,根据字段的赋值进行组合,拼接为最终的查询子句。逻辑查询条件和子查询条件还能通过复用查询对象进行构造。这两个优势是SQL作为静态语言所不具备的。
通过以上方式定义查询对象后,开发人员不再需要显示编写if语句拼接查询条件。框架可以通过反射技术读取每个字段的赋值,将每个查询参数的赋值判断和查询条件的拼接隐式得包含在框架代码里,从而大大简化了动态查询的代码编写和维护。
此外,上述查询对象还能用于构建MongoDB的查询语句:
{
"$and": [
{"age": {"$gt": {}}},
{"age": {"$lte": {}}},
{"memo": null},
{"memo": {"$regex": {}}},
{"valid": {"$eq": {}}},
{"$or": [{}, {}, {}]}
]
}
(其中空对象类似于SQL中的占位符。)
由于所有的查询语言的设计都是基于同样的数学原理,所以我们基于这些数学原理设计的查询对象映射方法是可以适用于所有的面向对象编程语言和所有的数据库查询语言。
结论
在面向对象开发中,我们可以通过查询对象映射方法将查询对象构造为SQL语句中的查询子句。查询对象映射方法包括一种根据查询对象的字段赋值自动地将字段对应的查询条件动态组合为查询子句的方法,以及三种以上将字段构建为查询条件的方法,有效地简化了动态查询代码的编写和维护。
至于复杂查询语句中的聚合查询和连接查询,则可以通过一种视图对象映射方法进行构建。但是这已经不属于传统ORM的范畴了。我称之为对象查询映射(Object Query Mapping)。