重构ORM之4:视图对象和复杂查询

176 阅读8分钟

引言

在经过对动态查询的不断优化和重构后,我们已能够利用查询对象和实体对象为单表构建增删查改语句。本文将进一步介绍如何通过对象映射构建复杂查询语句的方案。

对于复杂查询语句的构造,我们可以复用查询对象的映射方法构造其中的查询子句。但是复杂查询语句中的多表连接、聚合计算以及分组过滤等语法无法实体对象进行映射。因此,我们需要一个设计新的对象来映射复杂查询语句,称为视图对象。


视图对象映射

复杂查询通常依赖于预定义的SQL字符串,并且需要定义相应的数据对象来映射查询结果。然而,复杂查询语句中的列通常涉及聚合计算或多个表的字段,而实体对象中的字段通常只对应单表的列。为了解决这个问题,我们提出了一种解决方案,通过转换数据对象,不仅能够映射查询结果,还能生成相应的聚合查询语句,从而实现复杂查询语句的自动映射。

基于此解决方案,我们引入了 视图对象(View Object) 来定义和构建复杂查询语句中的列,并引入了 Having 对象 来生成 HAVING 子句。查询语句的动态部分仍然使用现有的查询对象来构建。这种设计允许复用查询对象来构建复杂查询语句的动态部分,同时利用视图对象作为静态列和聚合列的载体,确保设计的简洁性和可维护性。

为了实现复杂查询的自动映射,我们将映射过程分为以下三个部分:

  1. 列映射(Columns Mapping):视图对象中定义的字段用于映射查询结果中所需的列,包括常规列和聚合列。
  2. 连接映射(Join Mapping):视图对象中的注解用于定义表之间的关系,从而自动生成必要的连接语句。
  3. HAVING 子句映射(HAVING Clause Mapping):使用 Having 对象 生成 HAVING 子句,支持基于聚合结果的过滤条件。

这种设计不仅提高了开发人员构建和维护复杂查询语句的效率,还减少了手动拼接SQL语句的风险,显著增强了代码的可读性、可重用性和可维护性。


列映射(Columns Mapping)

我们需要解决的第一个问题是如何通过视图对象生成 SELECT 子句中的表达式和分组子句中的列。

聚合列映射(Aggregate Columns Mapping)

聚合函数的应用是聚合查询的核心。所有聚合列查询的数据都需要映射到视图类中定义的字段,因此,我们可以反过来将字段名称映射为聚合列。其中,前缀映射是一种有效的映射方案。

前缀聚合函数名称字段名聚合表达式
sumsumsumScoresum(score) AS sumScore
maxmaxmaxScoremax(score) AS maxScore
minminminScoremin(score) AS minScore
avgavgavgScoreavg(score) AS avgScore
firstfirstfirstScorefirst(score) AS firstScore
lastlastlastScorelast(score) AS lastScore
stdDevPopstddev_popstdDevPopScorestddev_pop(score) AS stdDevPopScore
stdDevSampstddev_sampstdDevSampScorestddev_samp(score) AS stdDevSampScore
stdDevstddevstdDevstddev(score) AS stdDev
addToSetaddToSetaddToSetScoreaddToSet(score) AS addToSetScore
pushpushpushScorepush(score) AS pushScore
countcountcountScorecount(score) AS countScore
countcount(*) AS count

示例:如果我们要计算名为 score 的列的平均值,我们将使用 avg 函数。然后我们可以将字段名称定义为 avgScore,遵循聚合前缀加列名的格式,在构建查询语句时将其映射为 avg(score) AS avgScore

表达式列映射(Expression Columns Mapping)

对于难以定义为字段名称的复杂聚合表达式,我们可以使用包含表达式的注解来映射。表达式映射到列名,字段名称映射到标签名称。

示例:我们定义一个带有表达式注解的字段:

@Column(name = "sum(l_extendedprice*(1-l_discount))")
private BigDecimal sum_disc_price;

字段 sum_disc_price 将被映射为:

sum(l_extendedprice*(1-l_discount)) AS sum_disc_price

GROUP BY 列映射(GROUP BY Columns Mapping)

对于 GROUP BY 子句,由于分组列在大多数情况下需要返回,我们可以简单地在视图类中定义的字段上添加注解,声明它们是分组列,并在映射时将其放在 GROUP BY 之后。

示例:我们在视图类中有两个带有 @GroupBy 的字段:

@GroupBy
private int l_returnflag;
@GroupBy
private int l_linestatus;

它们将被映射为:

SELECT l_returnflag, l_linestatus, ... FROM ...
GROUP BY l_returnflag, l_linestatus ...

连接映射(Join Mapping)

我们使用 TPC-H 基准测试中的第 3 个查询(Shipping Priority Query)来说明如何映射表连接。查询语句如下:

SELECT l_orderkey, SUM(l_extendedprice * (1 - l_discount)) AS revenue, o_orderdate, o_shippriority
FROM customer, orders, lineitem
WHERE o_custkey = c_custkey
  AND l_orderkey = o_orderkey
  AND c_mktsegment = ?
  AND o_orderdate < ?
  AND l_shipdate > ?
GROUP BY l_orderkey, o_orderdate, o_shippriority
ORDER BY revenue DESC, o_orderdate

这是一个典型的连接查询,包含两个需要处理的关键部分:

  1. 表名列表:FROM customer, orders, lineitem
  2. 连接条件:WHERE o_custkey = c_custkey AND l_orderkey = o_orderkey

多表名映射(Multiple Table Names Mapping)

定义:为了映射连接表,我们定义了注解 @ComplexView@View 来配置视图类上的连接实体:

@Target(TYPE)
@Retention(RUNTIME)
public @interface ComplexView {
    View[] value();
}

@Target(TYPE)
@Retention(RUNTIME)
@Repeatable(ComplexView.class)
public @interface View {
    Class<?> value();
    String alias() default "";
    ViewType type() default ViewType.TABLE_NAME;
}

配置:对于 Shipping Priority 查询,我们定义了一个 ShippingPriorityView 类,并使用 @View 注解配置如下:

@View(CustomerEntity.class)
@View(OrdersEntity.class)
@View(LineitemEntity.class)
public class ShippingPriorityView {
    //...
}

解析:从三个实体的注解配置中可以轻松解析出三个表名,从而得到 FROM customer, orders, lineitem

连接条件映射(Joining Conditions Mapping)

定义:为了映射这些实体之间的连接条件,我们需要首先定义实体之间的关系。我们定义了注解 @ForeignKey,用于配置实体的外键字段,以确定两个实体之间的关系。

@Target(FIELD)
@Retention(RUNTIME)
public @interface ForeignKey {
    Class<?> entity();
    String field();
}

配置:此注解配置在外键字段上,用于指定相关实体及其主键字段。以下是一个示例:

public class OrdersEntity extends AbstractPersistable<Long> {
  private String o_orderkey;
  @ForeignKey(entity = CustomerEntity.class, field = "c_custkey")
  private String o_custkey;
  //...
}

解析:当此类实体通过 @View 注解配置时,我们会扫描其他连接实体,查找是否存在由 @ForeignKey 配置的实体,如果找到,则添加连接条件。在 ShippingPriorityView 类中,我们为 OrdersEntity 的字段 o_custkey 找到了 CustomerEntity,因此我们在 WHERE 子句后添加条件 o_custkey = c_custkey。另一个条件 l_orderkey = o_orderkey 也是如此。

示例:我们展示一个完整的示例,展示从视图和查询对象到 SELECT 语句的映射过程。


Having 对象(The Having Object)

SQL 中的 HAVING 子句用于对聚合后的分组结果进行过滤,是 SELECT 语句的可选部分,语法类似于 WHERE 子句。因此,我们设计了一个新对象,称为 Having 对象,用于映射 HAVING 子句。 Having对象映射过程可以复用查询对象的映射算法。我们同时采用前缀映射和后缀映射来映射 Having 对象中声明的字段。例如,Having 对象中定义的字段 avgScoreGe 将被映射为 HAVING avg(score) >= ?

在 Java 中,我们让 Having 对象实现一个空的 Having 接口,以标记其构建 HAVING 子句的功能。此外,我们可以让 Having 对象继承查询对象,而查询对象继承 PageQuery,从而形成三级结构。对于 Having 对象的实例,PageQuery 中声明的字段用于构建排序和分页子句;查询对象中声明的字段用于构建 WHERE 子句;Having 对象中声明的字段用于构建 HAVING 子句。


聚合查询接口(Aggregate Query Interface)

为了支持根据视图对象进行复杂查询,我们定义了一个新的接口 AggregateClient,该接口可使开发人员通过视图对象和查询对象完成复杂查询操作:

public interface AggregateClient {
  <V> List<V> query(Class<V> viewClass, Query query);
}

以下代码展示了如何使用此接口执行TPC-H的第3条查询语句:

Date date = Date.valueOf(LocalDate.of(1995, 3, 15));
ShippingPriorityQuery query = ShippingPriorityQuery.builder()
  .c_mktsegment("BUILDING")
  .o_orderdateLt(date).l_shipdateGt(date)
  .sort("revenue,DESC;o_orderdate").build();
List<ShippingPriorityView> list = aggregateClient.query(ShippingPriorityView.class, query);

这种方法将查询语句的构建逻辑与业务逻辑分离。每次执行查询时,开发人员只需根据查询参数构建一个 ShippingPriorityQuery 对象即可完成操作。

结论

本文提出视图对象及其映射方法,再配合Page/Query/Having对象这样的层级设计映射查询条件,充分利用面向对象编程的特性,实现了通过对象自动构建复杂查询语句的功能,显著提高了复杂查询的开发效率和可维护性。

© 2024 Yuan Zhen. All rights reserved.