本文探讨了如何利用查询对象实例自动生成动态查询条件,通过将查询条件抽象为四种字段类型(谓词后缀字段、逻辑后缀字段、子查询字段和实体关系字段)以实现查询条件的自动构造。

1. 引言

在对使用if语句构建动态查询的代码进行重构后,我们实现了一种可以通过查询对象的实例直接构建动态查询的方法。这种方法包括以下步骤:

  1. 遍历查询对象的所有字段;
  2. 通过反射获取字段的赋值,根据已赋值字段的注解得到对应的查询条件,并将字段的赋值添加到参数列表。
  3. 使用AND将多个查询条件组合成查询子句。

借助这种方法,每新增一个查询条件,只需在查询对象中添加一个字段和声明了SQL条件的注解即可。
并且由于其通用性,构建动态查询的实现方法可以放在单独的仓库中进行维护和发布。

然而,随着查询条件的类型和数量的增加,通过注解声明查询条件的弊端也逐渐显现,主要存在以下问题:

  1. 查询条件中的列名和字段名称存在重复;
  2. IN操作符的变长占位符需要特殊处理;
  3. 需要使用OR运算符连接多个查询条件;
  4. 需要支持子查询。

通过分析,我们发现操作符为等号的查询条件可以直接从字段名映射而得,从而避免在注解中重复编写SQL条件。这也让我们发现了消除SQL注解这个优化方向。

2. 查询条件归类

基于查询条件的特性,我们将其分为以下四类,每类查询条件由一种类型的字段映射得到:

  • 包含列名、运算符和参数的基础查询条件
  • 使用AND/OR连接的查询条件
  • 含有子查询的查询条件
  • 涉及多对多关系的查询条件

2.1. 谓词后缀字段

基础查询条件由列名、比较运算符和参数三部分构成,可以通过谓词后缀字段自动映射。

谓词后缀字段的命名格式通常为列名加上一个谓词后缀,这些后缀可以直接表示常见的比较运算符,例如,Eq代表等于=Gt代表大于>等等。这种设计在DSL(领域特定语言)中非常常见。

使用谓词后缀对字段进行命名还有另一个好处。当需要为某列定义比较符不同的多个查询条件时,不同的谓词后缀可以避免字段间的命名冲突。

UserQuery类为例,原先通过@QueryField注解声明查询条件的代码可以被简化成以下形式:

public class UserQuery {
    private String name;     // name = ?
    private String nameLike; // name LIKE ?
    private Integer ageGt;   // age > ?
}

在根据谓词后缀构造查询条件时,我们还会根据字段的赋值情况动态生成占位符和处理参数。例如:

  • 对于IN查询条件,可以根据参数个数自动生成对应个数的占位符;
  • 对于LIKE查询条件,则根据谓词后缀为字段值自动添加%。详见附录A谓词后缀表的对应部分。

这种设计还让查询对象的构建更加直观。当为查询对象的字段赋值时,开发者可以直接根据字段名及其后缀的含义明确对应的SQL查询条件。

附录A中的谓词后缀表列出了常见的谓词后缀和SQL查询条件的映射关系,为使用这些字段定义提供了参考。

2.2. 逻辑后缀字段

由逻辑运算符AND或OR连接的多个查询条件,由逻辑后缀字段映射得到。
逻辑后缀字段的类型为集合或者查询对象,用于映射多个查询条件,其中每个字段或元素映射为一个查询条件。
逻辑后缀字段的名称中包含逻辑后缀And/Or,用于指定连接多个查询条件的逻辑运算符。

这张表格展示了以Or为后缀的三种字段类型的逻辑后缀字段的映射示例:

字段 取值 (JSON格式) 查询条件
List<String> nameOr ["test1","test2"] name = ? OR name = ?
UserQuery userOr {"name": "John", "age": 30} name = ? OR age = ?
List<UserQuery> usersOr [{"name": "test", "ageGe": 30, "ageLt": 40}, {"nameLike": "admin", "age": 40}] (name = ? AND age >= ? AND age < ?) OR (name LIKE ? AND age = ?)

例如,查询条件id = ? OR name = ? AND age > ?,我们可以在UserQuery类中添加字段userAnduserOr,用于映射逻辑运算符连接的查询条件。

public class UserQuery {
    Integer id; 
    String name; 
    String nameLike;
    Integer ageGt; 
    UserQuery userAnd; 
    UserQuery userOr; 
}

再通过以下代码构建出UserQuery的实例,由字段userOr生成对应的查询条件:

UserQuery userAnd = UserQuery.builder().name("John").ageGt(30).build();
UserQuery userOr = UserQuery.builder().id(5).userAnd(userAnd).build();
UserQuery userQuery = UserQuery.builder().userOr(userOr).build();

List<Object> argList = new ArrayList<>();
String where = buildWhere(query, argList); 
// SQL: WHERE id = ? OR name = ? AND age > ?

在这个示例当中,同时存在逻辑运算符AND和OR,其中,AND的优先级高于OR。
由于逻辑运算符的运算优先级不同,我们认为SQL中的组合条件不是平面的,而是有层级的。
通过AND连接的查询条件位于一个层级,
通过OR连接的查询条件位于另一个层级。
不同层级的查询条件由相同的逻辑运算符组合而成。
基于以上发现,我们可以利用对象的层级关系来表达查询子句的层级关系。

下图展示了UserQuery的实例中的赋值字段与查询条件的层级对应关系:
逻辑运算符层级对比图

2.3. 子查询字段

对于一般的子查询条件,例如age > (SELECT avg(age) FROM t_user [WHERE]),我们可以将其分为三个部分分别进行映射:

  1. 条件部分age >,我们可以复用谓词后缀字段的格式进行映射。但是为了避免和原有的谓词后缀字段ageGt产生命名冲突,我们需要在谓词后缀后再加一些字符进行区分,例如ageGtAvg。在映射时,忽略谓词后缀后的额外字符;

  2. 子查询的主句部分SELECT avg(age) FROM t_user:将列名和表名作为不变的静态变量,通过注解声明,例如@Subquery(select = "avg(age)", from = "t_user")

  3. 子查询的WHERE子句部分:通过复用查询对象映射方法进行构建。子查询字段的类型需要为查询对象。

通过这种方法,我们可以在UserQuery中添加如下ageGtAvg字段,用于构建上述查询条件:

public class UserQuery {
  // age > (SELECT avg(age) FROM t_user [WHERE])
  @Subquery(select = "avg(age)", from = "t_user")
  UserQuery ageGtAvg;
}

这样,我们仅使用一个字段便可以构建包含子查询的查询条件,且能为子查询构建动态查询语句。

2.4. 实体关系查询字段

为符合第三范式,对于具有多对多关系的两个实体,我们通常引入一个中间表,将多对多关系拆分为两个一对多关系。而这类中间表的表名和字段都具有特定的格式:表名包含两个实体的名称;两个外键也以entity_id的格式命名。这使得我们可以通过两个实体的名称就能推导出查询条件。

以RBAC模型为例,具有用户、角色和权限三个实体。其中,用户和角色具有多对多关系。其中间表的表名就可以由user和role推出,例如a_user_and_role,中间表的两个外键名称可以是user_id和role_id。那么,通过角色查询用户的查询条件为:

SELECT * FORM t_user
WHERE id IN (SELECT user_id FROM a_user_and_role WHERE role_id IN (
  SELECT id FROM t_role [WHERE]))

由于我们可以由实体名称user和role推出各自对应的表名,以及对应的中间表的表名和两个外键名称,进而推出上述的查询条件,我们通过表达式<user,role>来表示上述查询条件。而对于role表后的WHERE子句,我们可以通过对实体role对应的查询对象复用查询对象映射方法得到。

由于角色和权限也具有多对多关系,这使得用户和权限也符合多对多关系的特征,我们使用表达式<user,role,perm>来表示用户和权限通过角色形成的这种多对多关系,对应的推出根据权限查询所分配给的用户的查询语句为:

SELECT * FORM t_user
WHERE id IN (SELECT user_id FROM a_user_and_role WHERE role_id IN (
  SELECT role_id FROM a_role_and_perm WHERE perm_id IN (
    SELECT id FROM t_perm [WHERE])))

由于表达式<user,role,perm>在表示user和perm的关系时,形成了一条从user到perm的路径,并且路径的节点为具有多对多关系的实体的名称,所以我称之为抽象实体路径。

抽象实体路径可以包含任意多个节点来表示两个实体间的关系。例如,当系统中的权限和菜单也具有多对多关系时,可以使用表达式<user,role,perm,menu>表示用户和菜单中间的多对多关系。以此类推。

根据以上分析,我们将通过角色和权限查询用户的字段分别定义如下:

public class UserQuery {
  //...
  @DomainPath({"user", "role"})
  RoleQuery role;
  @DomainPath({"user", "role", "perm"})
  PermQuery perm;
}

根据角色名称查询所授予的用户,使用角色名称构建RoleQuery对象,再将其赋给UserQuery实例的role字段即可:

RoleQuery roleQuery = RoleQuery.builder().roleName("vip").build();
UserQuery userQuery = UserQuery.builder().role(roleQuery).build();
String where = buildWhere(userQuery, argList);

对应的查询子句如下:

WHERE id IN (SELECT user_id FROM a_user_and_role WHERE role_id IN (
  SELECT id FROM t_role WHERE role_name = ?))

不仅如此,当抽象实体路径仅包含一个节点时,还能用于表示一对多/多对一关系,仅仅需要再指定一个外键的名称。

例如在菜单实体中,通过外键parent_id连接父菜单的id,其查询字段看定义如下:

public class MenuQuery extends PageQuery {

    // many-to-one: query submenus by parent's conditions
    // parent_id IN (SELECT id FROM t_menu [WHERE])
    @DomainPath(value = "menu", localField = "parentId")
    private MenuQuery parent;

    // one-to-many: query parent menus by children's conditions
    // id IN (SELECT parent_id FROM t_menu [WHERE])
    @DomainPath(value = "menu", foreignField = "parentId")
    private MenuQuery children;
}

我们可以把父子菜单的关系表示为<menu:menu|parentId,id>和<menu:menu|id,parentId>。

实体关系查询字段为我们提供了一种开箱即用的针对实体关系的查询模式。

3. 应用范围

由于查询对象的定义仅依赖于面向对象语言的基本特性,这使得这种方法能够适用于不同的面向对象编程语言。不同类型的数据库在查询条件的语法上具有一定的共性,因此,基于查询对象的映射方案不仅能够生成SQL语句,还能够为MongoDB等NoSQL数据库构建查询语句。

下图展示了如何通过统一的查询对象模型,构建适用于不同数据库的查询条件。该方法的通用性使得我们能够在多个数据库系统之间共享相同的查询构建逻辑,从而提高了代码的复用性和系统的灵活性。

查询对象对比图

4. 总结

本文详细介绍了如何基于查询对象实例自动生成动态查询条件,提出一种以字段特征映射查询条件的改进方法。该方法将查询条件抽象为四种字段类型以实现查询条件的自动构造。该方法减少了手动SQL编写的需求,大幅提高了代码的通用性和扩展性,为复杂查询提供了一种清晰高效的实现方案。


附录A:谓词后缀表

后缀名称 字段名称 字段赋值 SQL查询条件 MongoDB查询条件
- id 5 id = 5 {"id":5}
Eq idEq 5 id = 5 {"idEq":5}
Ne idNe 5 id != 5 {"idNe":{"$ne":5}}
Gt idGt 5 id > 5 {"idGt":{"$gt":5}}
Ge idGe 5 id >= 5 {"idGe":{"$gte":5}}
Lt idLt 5 id < 5 {"idLt":{"$lt":5}}
Le idLe 5 id <= 5 {"idLe":{"$lte":5}}
In idIn [1,2,3] id IN (1,2,3) {"id":{"$in":[1, 2, 3]}}
NotIn idNotIn [1,2,3] id NOT IN (1,2,3) {"id":{"$nin":[1, 2, 3]}}
Null memoNull false memo IS NOT NULL {"memo":{"$not":{"$type", 10}}}
Null memoNull true memo IS NULL {"memo":{"$type", 10}}
NotLike nameNotLike "arg" name NOT LIKE '%arg%' {"name":{"$not":{"$regex":"arg"}}}
Like nameLike "arg" name LIKE '%arg%' {"name":{"$regex":"arg"}}
NotStart nameNotStart "arg" name NOT LIKE 'arg%' {"name":{"$not":{"$regex":"^arg"}}}
Start nameStart "arg" name LIKE 'arg%' {"name":{"$regex":"^arg"}}
NotEnd nameNotEnd "arg" name NOT LIKE '%arg' {"name":{"$not":{"$regex":"arg$"}}}
End nameEnd "arg" name LIKE '%arg' {"name":{"$regex":"arg$"}}
NotContain nameNotContain "arg" name NOT LIKE '%arg%’ {"name":{"$not":{"$regex":"arg"}}}
Contain nameContain "arg" name LIKE '%arg%’ {"name":{"$regex":"arg"}}
Rx nameRx "arg\d" name REGEXP 'arg\d’ {"name":{"$regex":"arg\d"}}

附录B:GitHub

Java version: http://github.com/doytowin/doyto-query

Golang version: http://github.com/doytowin/goooqo