编程

如何使用 JPA 和 Hibernate 调用原生 SQL 查询

1142 2024-07-15 03:54:00

Java 持久化查询语言(JPQL)是使用 JPA 从数据库中查询数据的最常见方法。它使你能够重用映射定义,并且比 SQL 更易于使用。但它只支持 SQL标准的一小部分,而且也不支持数据库特定的功能。

那么,如果要使用特定于数据库的查询功能,或者 DBA 为你提供了一个无法转换为 JPQL 的高度优化的查询,该怎么办呢?忽略它并在 Java 代码中完成所有工作?

当然不是!JPA 有自己的查询语言,但它被设计成一个泄漏的抽象,并支持原生 SQL 查询。你可以以与 JPQL 查询类似的方式创建这些查询,如果需要,它们甚至可以返回托管实体。

本文将向你展示如何使用原生 SQL 查询,以及将查询结果映射到 DTO 和实体对象的不同选项,并避免常见的性能缺陷。

定义并执行原生查询

与  JPQL 查询一样,你可以点对点定义原生 SQL 查询,也可以使用注释来定义命名的原生查询。

创建点对点原生查询

创建原生查询很简单。EntityManager 接口为此提供了 createNativeQuery 方法。它返回了 Query 接口的失效,这与调用 createQuery 方法创建 JPQL 查询时得到的结果相同。

下面的代码片段显示了一个使用原生查询从 author 表中选择名字和姓氏的简单示例。我知道没有必要对原生 SQL 查询执行此操作。我可以使用一个标准的 JPQL 查询来实现这一点,但我想把重点放在 JPA 部分,而不是用一些疯狂的 SQL 内容来打扰你😉

持久化提供者不解析 SQL 语句,因此你可以使用数据库支持的任何 SQL 语句。例如,在我最近的一个项目中,我使用它来使用 Hibernate 查询 PostgreSQL 特定的 jsonb 字段,并将查询结果映射到 POJO 和实体。

Query q = em.createNativeQuery("SELECT a.firstname, a.lastname FROM Author a");

List<Object[]> authors = q.getResultList();

for (Object[] a : authors) {

    System.out.println("Author "

            + a[0]

            + " "

            + a[1]);

}

如你所见,你可以像使用任何 JPQL 查询一样使用创建(create)查询。我没有为结果提供任何映射信息。因此,EntityManager 会返回一个 Object[]  的列表,你需要在之后进行处理。你也可以提供额外的映射信息,让 EntityManager 进行映射,而不是自己映射结果。在本文末尾的结果处理部分,我将对此进行详细介绍。

创建命名原生查询

如果我告诉你,命名原生查询的定义和用法与命名 JPQL 查询非常相似,你不会感到惊讶。

在前面的代码片段中,我创建了一个动态原生查询来查询所有作者的姓名。我在下面的代码片段中使用相同的语句来定义 @NamedNativeQuery。由于 Hibernate 5 和 JPA 2.2,这个注释是可重复的,你可以将多个注释添加到实体类中。如果使用的是较旧的 JPA 或 Hibernate 版本,则需要将其封装在 @NamedNativeQueries 注释中。

@NamedNativeQuery(name = "selectAuthorNames",

                  query = "SELECT a.firstname, a.lastname FROM Author a")

@Entity

public class Author { ... }

如你所见,该定义看起来与命名 JPQL 查询的定义非常相似。正如我将在下一节中向你展示的那样,你甚至可以包括结果映射。但稍后会详细介绍。

你可以使用与命名 JPQL 查询完全相同的 @NamedNativeQuery。你只需要将命名的原生查询的名称作为参数提供给 EntityManagercreateNamedQuery 方法。

Query q = em.createNamedQuery("selectAuthorNames");

List<Object[]> authors = q.getResultList();

for (Object[] a : authors) {

    System.out.println("Author "

            + a[0]

            + " "

            + a[1]);

}

参数绑定

与 JPQL 查询类似,你可以也应该为查询参数使用参数绑定,而不是将值直接放入查询字符串中。这有几个优点:

  • 你无需担心 SQL 注入,
  • 持久化提供者将你的查询参数匹配到正确的类型,并且
  • 该持久化提供者可以作内部优化以提升性能。

JPQL 和原生 SQL 查询使用同样的 Query 接口,它提供了 setParameter 方法用于位置和命名参数绑定。但原生查询的命名参数绑定支持是一个 Hibernate 特有的功能。位置参数在原生参数中被引用为 “?”,其编号从 1 开始。

下面的代码片段显示了一个带有位置绑定参数的特殊本原生 SQL 查询的示例。你可以在 @NamedNativeQuery 中以相同的方式使用绑定参数。

Query q = em.createNativeQuery("SELECT a.firstname, a.lastname FROM Author a WHERE a.id = ?");

q.setParameter(1, 1);

Object[] author = (Object[]) q.getSingleResult();

System.out.println("Author "

        + author[0]

        + " "

        + author[1]);

Hibernate 还支持原生查询的命名参数绑定,但正如我已经说过的,这不是由规范定义的,可能无法移植到其他 JPA 实现。

通过使用命名参数绑定,可以为每个参数定义一个名称,并将其提供给 setParameter 方法以将值绑定到该方法。该名称区分大小写,需要添加 “:” 符号作为前缀。

Query q = em.createNativeQuery("SELECT a.firstname, a.lastname FROM Author a WHERE a.id = :id");

q.setParameter("id", 1);

Object[] author = (Object[]) q.getSingleResult();

System.out.println("Author "

        + author[0]

        + " "

        + author[1]);

结果处理

正如你在前面的代码中所看到的,原生查询返回一个 Object[]Object[] 的列表(List)。如果要将查询结果检索为不同的数据结构,则需要向持久性提供程序提供额外的映射信息。有三种常用选项:

  • 你可以使用实体的映射定义将查询结果的每条记录映射到托管实体。
  • 你可以使用 JPA 的 @SqlResultSetMapping 注释将每个结果记录映射到 DTO、托管实体或标量值的组合。
  • 你可以使用 Hibernate 的 ResultTransformer 将每条记录或整个结果集映射到 DTO、托管实体或标量值。

使用实体映射

重用实体类的映射定义是将查询结果的每条记录映射到托管实体对象的最简单方法。执行此操作时,需要使用实体的映射定义中使用的别名来选择实体类映射的所有列。

接下来,你需要告诉持久化提供者,它将把查询结果映射到哪个实体类。对于特别的原生 SQL 查询,你可以通过提供类引用作为 createNativeQuery 方法的参数来实现这一点。

Query q = em.createNativeQuery("SELECT a.id, a.version, a.firstname, a.lastname FROM Author a", Author.class);

List<Author> authors = (List<Author>) q.getResultList();

for (Author a : authors) {

    System.out.println("Author "

            + a.getFirstName()

            + " "

            + a.getLastName());

}

你可以通过将实体类作为 @NamedNativeQuery  的 resultClass 属性来使用 @NamedNativeQuery

@NamedNativeQuery(name = "selectAuthorEntities",

                  query = "SELECT a.id, a.version, a.firstname, a.lastname FROM Author a",

                  resultClass = Author.class)

@Entity

public class Author { ... }

然后 Hibernate 会在执行查询时自动应该该映射。

使用 JPA 的 @SqlResultSetMapping

JPA 的 @SqlResultSetMapping 比前者更为灵活。你不仅可以使用它将查询结果映射到托管实体对象,还可以映射到 DTO、标量值以及它们的任何组合。唯一的限制是 Hibernate 将定义的映射应用于结果集的每个记录。因此,你无法轻松地对结果集的多个记录进行分组。

这些映射非常强大,但它们的定义可能会变得复杂。这就是为什么我在这篇文章中只提供一个简短的介绍。

下例中你可以看到一个基础的 DTO 映射示例:

@SqlResultSetMapping(

        name = "BookAuthorMapping",

        classes = @ConstructorResult(

                targetClass = BookAuthor.class,

                columns = {

                    @ColumnResult(name = "id", type = Long.class),

                    @ColumnResult(name = "firstname"),

                    @ColumnResult(name = "lastname"),

                    @ColumnResult(name = "numBooks", type = Long.class)}))

每个 @SqlResultSetMapping 都必须在持久化单元中有唯一的名称(name)。你可以在代码中使用它来引用映射的定义。

@ConstructorResult 注释告诉 Hibernate 调用 BookAuthor 类的构造函数并提供其结果集 idfirstNamelastNamenumBooks 字段作为参数。这使得你可以实例化非托管 DTO 对象,这对于只读操作非常适合。

在定义完映射后,你可以将其名称作为第二个参数提供给 createNativeQuery 方法。Hibernate 将在当前的持久化单元中查看映射的定义并将其适用到每个记录的结果集中。

Query q = em.createNativeQuery("SELECT a.id, a.firstname, a.lastname, count(b.id) as numBooks FROM Author a JOIN BookAuthor ba on a.id = ba.authorid JOIN Book b ON b.id = ba.bookid GROUP BY a.id",

                               "BookAuthorMapping");

List<BookAuthor> authors = (List<BookAuthor>) q.getResultList();

for (BookAuthor a : authors) {

    System.out.println("Author "

            + a.getFirstName()

            + " "

            + a.getLastName()

            + " wrote "

            + a.getNumBooks()

            + " books.");

}

类似于前面的示例,你可以通过将映射的名称作为 resultSetMapping 的属性将同样的映射应用到 @NamedNativeQuery

@NamedNativeQuery(name = "selectAuthorValue",

                  query = "SELECT a.id, a.firstname, a.lastname, count(b.id) as numBooks FROM Author a JOIN BookAuthor ba on a.id = ba.authorid JOIN Book b ON b.id = ba.bookid GROUP BY a.id",

                  resultSetMapping = "BookAuthorMapping")

@Entity

public class Author { ... }

然后,你可以执行你 @NamedNativeQuery,而 Hibernate 将自动应用 @SqlResultSetMapping

Query q = em.createNamedQuery("selectAuthorValue");

List<BookAuthor> authors = (List<BookAuthor>) q.getResultList();

for (BookAuthor a : authors) {

    System.out.println("Author "

            + a.getFirstName()

            + " "

            + a.getLastName()

            + " wrote "

            + a.getNumBooks()

            + " books.");

}

使用 Hibernate 特有的 ResultTransformer

ResultTransformers 是一个 Hibernate 特有功能,它与 JPA 的 @SqlResultSetMapping 有相同的目标。它们允许你定义原生查询结果集的自定义映射。但与 @SqlResultSetMapping 不同的是,你可以将该映射实现为 Java 代码,并且可以映射每个记录或整个结果集。

Hibernate 提供了一套标准的转换器(Transformer)而且自定义转换器的实现在 Hibernate 6 中变得容易多了。

下面代码显示了 Hibernate 6 中 TupleTransformer 的实现。它应用了前面用过的 @SqlResultSetMapping 同样的映射。

List<BookAuthor> authors = (List<BookAuthor>) session

        .createQuery("SELECT a.id, a.firstname, a.lastname, count(b.id) as numBooks FROM Author a JOIN BookAuthor ba on a.id = ba.authorid JOIN Book b ON b.id = ba.bookid GROUP BY a.id")

        .setTupleTransformer((tuple, aliases) -> {

                log.info("Transform tuple");

                BookAuthor a = new BookAuthor();

                a.setId((Long) tuple[0]);

                a.setFirstName((String) tuple[1]);

                a.setLastName((String) tuple[2]);

                a.setNumBooks((Integer) tuple[3]);

                return a;

        }).getResultList();

for (BookAuthor a : authors) {

    System.out.println("Author "

            + a.getFirstName()

            + " "

            + a.getLastName()

            + " wrote "

            + a.getNumBooks()

            + " books.");

}

如你在上面代码中所见,我调用了 setTupleTransformer 方法并将该转换器(Transformer)添加到该查询中。这使得该转换器独立于查询,并且你可以以同样的方式将其应用到 @NamedNativeQuery

定义查询空间,以避免性能问题

本文的开头,我提到 Hibernate 不解析原生 SQL 语句。这提供了一个好处,即你不被 Hibernate 支持的功能所局限,而是可以使用数据库支持的所有功能。

但这也使得无法确定查询空间。查询空间描述查询引用的实体类。Hibernate 使用它来优化在执行查询之前必须执行的脏检查和刷新操作。

使用原生 SQL 查询时需要知道的一件重要事情是指定查询空间。你可以通过从 JPA 的 Query 接口打开(unwrap) Hibernate 的 SynchronizeableQuery,并使用对实体类的引用调用 addSynchronizedEntityClass 方法来实现这一点。

Query q = em.createNamedQuery("selectAuthorEntities");

SynchronizeableQuery hq = q.unwrap(SynchronizeableQuery.class);

hq.addSynchronizedEntityClass(Author.class);

List<Author> authors = (List<Author>) q.getResultList();

for (Author a : authors) {

    System.out.println("Author "

            + a.getFirstName()

            + " "

            + a.getLastName());

}

这就告诉了 Hibernate 你的查询引用了哪个实体类。然后它可以将脏检查限制在这些实体类的对象上,并将它们刷新到数据库中。在执行此操作时,Hibernate 会忽略其他实体类的实体对象上的所有更改。这避免了不必要的数据库操作,并允许 Hibernate 应用进一步的性能优化。

结论

JPQL 是 JPA 和 Hibernate 最常用的查询语言。它提供了一种从数据库中查询数据的简单方法。但它只支持 SQL 标准的一小部分,而且也不支持特定于数据库的功能。如果要使用这些功能中的任何一个,则需要使用原生 SQL 查询。

你可以通过调用 EntityManagercreateNativeQuery 方法并提供 SQL 语句作为参数来定义原生特别查询。或者,你可以使用 @NamedNativeQuery 注释来定义一个命名查询,该查询可以以与 JPQL 的 @NamedQuery 相同的方式执行。

原生查询将其结果作为 Object[] 或者 List <Object[]> 返回。你可以以多种方式转换。如果你查询实体类映射的所有字段,你可以提供类引用作为 createNativeQuery 方法的第二个参数。然后,Hibernate 将该类的映射应用于结果集中的每个记录,并返回托管实体对象。如果要将结果映射到 DTO,则需要定义 @SqlResultSetMapping 或实现 Hibernate 特定的 ResultTransformer

你应该始终定义原生查询的查询空间。它使 Hibernate 能够优化在执行查询之前需要执行的脏检查和刷新操作。