ORM的设计者和开发者从来没有意识到他们真正需要面对和解决的问题是什么?一旦这个问题得到解决,ORM将不再是ORM。

动态查询问题和组合数学

我们首先回顾一下什么是动态查询问题。

在信息系统中,开发人员向用户提供一个含有n个查询参数的查询接口,用户填写其中的k个查询参数向系统发起一次请求(k[0,n])(k \in [0, n] ),系统根据这k个查询参数构建一条查询语句。开发人员需要为任意k个查询参数组合对应的查询条件,这就是所谓的动态查询问题。

这个问题的核心在于用户为信息系统引入了一个不确定变量k。SQL语言在设计之初就是只面向开发人员的,并没有考虑到用户这个因素,因此,每条SQL语句都是静态的,且只对应一组查询条件。由于ORM框架并不解决这个问题,所以需要开发人员来根据用户的输入组合对应的查询条件。

假设n个查询参数相互独立且对应的查询条件的顺序固定,那么这个问题可以通过组合数学中的子集选择问题进行描述,即从查询接口提供的含有n个元素的集合中由用户选择k个元素构建一个子集。

k=0n(nk)=2n\sum_{k=0}^{n} \binom{n}{k} = 2^n

由公式可知,对于一个提供有n个查询参数的查询接口,一共可以构建2n2^n种查询子句。也就是说,如果提供3个查询参数,就需要编写8条查询子句,如果提供10个查询参数,就需要编写1024条查询子句。无论是编码还是维护都极其困难。

ORM框架并没有重视这个问题,更没有对应的解决方法。一种自然的方式是通过if语句对查询参数进行判断来确定是否将其对应的查询条件拼接为查询子句。由于每条if语句输出TRUE和FALSE两种判断结果,对应是否执行if块内的查询条件拼接操作,这样n条if语句正好有2n2^n种拼接结果。

看似这个问题得到解决,但是这种方案的问题在于需要为每个查询参数编写一段if语句。当查询参数越来越多时,if语句也会变得越来越多,代码维护起来依然困难。

这才是数据库访问中真正需要解决的问题,而不是进行对象和关系的映射。

查询对象映射方法

当我们使用引入参数对象(Introduce Parameter Object)的重构方法把所有的查询参数集中定义在一个对象中时,我们得到了一个含有n个字段的对象,并且查询子句的构建只与这个对象有关。

对于一个定义有n个字段的对象而言,每个字段可以有赋值和未赋值两种状态,n个字段的赋值组合正好有2n2^n种。如果对象中的每个字段都能构造一段查询条件,那么我们就可以利用对象的2n2^n种赋值组合来构造2n2^n种查询子句。

于是,我们把思路调整为通过对象来映射查询子句。我们将这种用于构建查询子句的对象称为查询对象,把根据查询对象的字段赋值将对应的查询条件组合为查询子句的方法称为查询对象映射方法

对象映射

对于支持反射的编程语言,我们可以通过反射读取各个字段的赋值,通过对赋值进行判断确定是否将其对应的查询条件组合为查询子句。整个算法描述如下:

  • 遍历查询对象实例的字段;
  • 通过反射获取每个字段的值,并将已赋值的字段映射为查询条件;
  • 使用逻辑运算符AND将查询条件组合成查询子句。

将字段映射为查询条件的一种最简单的方式是通过注解等方式将查询条件同字段一起声明。

反射技术和注解声明可以帮助我们将查询子句的构建代码封装为框架提供给所有开发人员使用,这将大大简化开发人员的工作。

例如,对于开发人员定义的如下UserQuery对象,框架可以基于上述算法根据UserQuery的赋值构建对应的查询子句:

public class UserQuery {
    @Condition("name = ?")
    private String name;
    @Condition("name LIKE CONCAT('%', ?, '%')")
    private String nameLike;
    @Condition("age > ?")
    private Integer ageGt;
}

在确定对象映射相较于if语句拼接方案的优势后,我们进一步讨论如何通过字段特征来构造不同的查询条件,以避免对字符串条件的维护。

字段映射

在SQL中,查询条件主要包括以下三类:

下面是优化后的内容,突出查询条件与数学原理的关系:

  1. 比较查询条件:例如 age > ?,该条件基于谓词逻辑(Predicate Logic)进行表示。谓词逻辑用于表达基本的比较运算,如等于(=)、大于(>)、小于(<)、大于等于(≥)、小于等于(≤)、不等于(≠)等。

  2. 逻辑查询条件:通过布尔代数(Boolean Algebra)进行组合,使用逻辑运算符如 ANDORNOT 来对多个条件进行逻辑运算。这些条件表示了多个查询条件之间的逻辑关系。例如,age > 30 AND valid = true,是两个条件通过“AND”运算符结合在一起的逻辑查询条件。

  3. 子查询条件:这类条件涉及一个嵌套的查询,通常是基于关系代数(Relational Algebra)。关系代数为数据库查询提供了数学基础,用于表达表之间的关系及嵌套查询。例如,age > (SELECT avg(age) FROM t_user) 是一个子查询条件,其中 avg(age) 作为子查询返回一个结果,并与外部查询的字段进行比较。

基于以上数学原理,我们设计三种字段用于构造查询条件。

通过谓词后缀字段映射比较查询条件。比较查询条件通常由列名、比较运算符和参数三部分构成。在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子句部分:通过复用查询对象映射方法进行构造。

通过以上三种字段,我们可以完成大部分查询条件的自动构造。对于其他查询条件,我们可以继续开发新的方法进行支持。

结果

通过对象映射和字段映射,查询对象现在具备了以下四个关键特性:

  1. 构造比较查询条件;
  2. 构造逻辑查询条件;
  3. 构造子查询条件;
  4. 基于查询参数动态组合查询条件。

从数学理论的角度来看,查询对象映射方法有效地处理了终端用户引入的不确定性因素,并能构造SQL语句中的各类查询条件,形成了一种基于对象的动态查询语言。

相较于SQL中静态的查询子句,基于对象的动态查询语言额外具备了动态组合查询条件这一特性。在此基础之上,我们可以继续构建SQL语句的其他部分。

示例

查询对象映射方法仅通过字段的元信息来构造对应的查询条件,因此可以适用于任何面向对象编程语言。我们以Java和Go语言的实现版本进行举例。

Java示例

public class UserQuery {// WHERE
  String nameLike       // AND name LIKE ?
  Integer ageGt;        // AND age > ?
  Integer ageLe;        // AND age <= ?
  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])
}
GitHub:http://github.com/doytowin/doyto-query

Go示例

type UserQuery struct {   // WHERE
	NameLike *string       // AND name LIKE ?
	AgeGt    *int          // AND age > ?
	AgeLe    *int          // AND age <= ?
	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"`
}
GitHub:http://github.com/doytowin/goooqo

其中,每个字段对应一个或一组查询条件,根据字段的赋值进行组合,拼接为最终的查询子句。逻辑查询条件和子查询条件还能通过复用查询对象进行构造。这两个优势是SQL作为静态语言所不具备的。

通过以上方式定义查询对象后,开发人员不再需要显示编写if语句拼接查询条件。框架可以通过反射技术读取每个字段的赋值,将每个查询参数的赋值判断和查询条件的拼接隐式得包含在框架代码里,从而大大简化了动态查询的代码编写和维护。

讨论

对于分页和排序,我们依然可以把相关参数定义在查询对象中,在通过查询对象构建SQL语句时,根据对应的参数构建分页子句和排序子句。我们只需要在定义相关参数时声明它们不用于构造查询条件即可。

我们还可以通过代码生成的方式,为查询对象生成之前手工编写的if语句拼接代码。这使得开发人员不需要额外编写代码的同时,还能消除反射方案带来的性能影响,并支持那些未提供反射的编程语言。

此外,上述查询对象还能用于构造MongoDB的查询语句:

{
  "$and": [
    {"age": {"$gt": {}}},
    {"age": {"$lte": {}}},
    {"memo": null},
    {"memo": {"$regex": {}}},
    {"valid": {"$eq": {}}},
    {"$or": [{}, {}, {}]}
  ]
}

(其中空对象类似于SQL中的占位符。)

由于所有的查询语言的设计都是基于同样的数学原理,MongoDB、Redis、ElasticSearch等NoSQL数据库都把自己的查询语言与SQL进行过对比和转换,有些数据库甚至直接支持部分SQL标准。因此,我们基于这些数学原理设计的查询对象映射方法不但适用于所有的面向对象编程语言,还可以用于适配所有的数据库查询语言。这已经超出ORM的理论范畴了。

至于复杂查询语句中的聚合查询和连接查询,则可以通过一种视图对象映射方法进行构造,不在本文赘述。

结论

本文将动态查询问题归结为组合数学中的子集选择问题,提出直接通过查询对象的赋值组合来处理查询条件的赋值组合问题,并充分利用对象和字段的特征来构造SQL语句中的各类查询条件,最终发展为一种基于对象的动态查询语言,成为一种比ORM更高效的数据库访问方案。