作者:Yuli Vasiliev
了解如何利用 Java 持久性查询语言和原生 SQL 查询 JPA 实体。
2008 年 9 月发布
在 Java EE 中,Java 持久性 API (JPA) 是用于访问关系数据库的标准 API,它提供了一种简单高效的方法来管理常规 Java 对象 (POJO) 到关系数据的对象/关系映射 (ORM)。此类 Java 对象称为 JPA 实体 或简称为实体。
实体通常(但不总是)与底层数据库中的单个关系表相关联,因此每个实体的实例表示 Java 中该表的某一行。实体与关系表类似,通常通过一对一、一对多、多对一或多对多等关系彼此关联。很显然,处理实体的 Java 应用程序需要一个标准机制来访问和导航实体实例。Java 持久性查询语言 (JPQL) 就是专门为此设计的。
在本文中,您将了解一些使用 JPQL 和原生 SQL 查询 Java 应用程序中使用的实体的有趣方法。
本文讨论的代码段摘自本文随附的示例应用程序中使用的 Java 源文件。浏览一下示例存档,您可能会注意到,这是一个基于 Java Servlet 和 Java 持久性 API 技术的简单 Web 应用程序。为简单起见,它不使用企业 bean,而是直接从 servlet 内部发出 JPQL 查询。但是,这并不意味着您将不能在企业 bean 中利用这里讨论的 JPQL 查询 — 您可以在任何 Java EE 组件中定义 JPQL 查询。
图 1 显示了示例实体结构。如您所见,它包含一组通过不同类型的关系彼此关联的实体。为了说明本文后面的“定义 JPQL 联接”部分中讨论的 JPQL 连接查询的用法,需要这样一个分支结构。
图 1 示例应用程序中使用的实体之间的关系
要详细了解如何设置和启动示例应用程序,可以参考示例存档根目录下的 readme.txt 文件。
如果您使用过数据库,您很可能已经使用过 SQL,这个标准的工具提供了一系列语句,用于访问和操作关系数据库中的信息。实际上,JPQL 和 SQL 有很多相似之处。归根结底,它们都用于访问和操作数据库数据。而且,二者都使用非过程语句 — 通过特殊解释程序识别的命令。此外,JPQL 在语法上与 SQL 也相似。
JPQL 和 SQL 的主要区别在于,前者处理 JPA 实体,后者直接处理关系数据。作为 Java 开发人员,您可能还有兴趣了解使用 JPQL 与 SQL/JDBC 的不同,无需在 Java 代码中使用 JDBC API — 容器在幕后为您完成了所有这些工作。
通过 JPQL,您可以使用 SELECT、UPDATE 或 DELETE 这三条语句之一来定义查询。值得注意的是,EntityManager API 接口提供的方法也可用于对实体执行检索、更新和删除操作。具体来说,是 find、merge 和 remove 方法。然而,这些方法的使用通常限于单个实体实例,当然,级联生效时除外。而 JPQL 语句则没有这样的限制 — 您可以定义对若干组实体进行批量更新和删除操作,并定义查询返回若干组实体实例。
要从 Java 代码内发出 JPQL 查询,您需要利用 EntityManager API 和 Query API 的相应方法,执行以下一般步骤:
EntityManager 接口方法以及 Query API 接口方法的完整列表可以在以下 Enterprise JavaBeans 3.0 规范中找到:Java 持久性 API 文档(JSR-220 的一部分)。
既然您已经对如何创建以及发出 JPQL 查询有了大致了解,您可能希望看一些实际的示例。以下代码片段摘自一个 servlet 的 doGet 方法,该方法使用 JPQL 查询获取有关与查询中指定的 Customer 实体相关的底层关系表中存储的所有客户的信息。
... @PersistenceUnit private EntityManagerFactory emf; public void doGet( ... EntityManager em = emf.createEntityManager(); PrintWriter out = response.getWriter(); Listarr_cust = (List )em.createQuery("SELECT c FROM Customer c") .getResultList(); out.println("List of all customers: "+"
"); Iterator i = arr_cust.iterator(); Customer cust; while (i.hasNext()) { cust = (Customer) i.next(); out.println(cust.getCust_id()+"
"); out.println(cust.getCust_name()+"
"); out.println(cust.getEmail()+"
"); out.println(cust.getPhone()+"
"); out.println("----------------" + "
"); } ...
当然,这里让人关注的是 EntityManager 实例的 createQuery 方法和 Query 实例的 getResultList 方法。EntityManager 的 createQuery 用于创建 Query 实例,然后该实例的 getResultList 方法用于执行作为参数传递给 createQuery 的 JPQL 查询。正如您可能已经猜到的那样,Query 的 getResultList 方法以 List 形式返回查询的结果,在这个特定示例中,将 List 的元素转换为 Customer 类型。
如果您需要检索单个结果,Query API 接口提供了 getSingleResult 方法,如以下示例所示。然而,请注意,如果您返回多个结果,使用 getSingleResult 将引发异常。
该示例还说明了如何使用 Query 的 setParameter 方法,通过该方法,您可以将参数值绑定到某个查询参数。使用 setParameter,您既可以绑定命名参数也可以绑定位置参数。但是,您在此处绑定的是一个命名参数。
... Integer cust_id =2; Customer cust = (Customer)em.createQuery("SELECT c FROM Customer c WHERE c.cust_id=:cust_id") .setParameter("cust_id", cust_id) .getSingleResult(); out.println("Customer with id "+cust.getCust_id()+" is: "+ cust.getCust_name()+"
"); ...
值得注意的是,如果检索单个实体实例,SELECT JPQL 语句并不是唯一的选择。您还可以使用 EntityManager 的 find 方法,通过该方法,您可以根据实体的 ID(作为参数传入)检索单个实体实例。
在某些情况下,您可能只需检索目标实体实例中的某些信息,针对特定的实体字段定义 JPQL 查询。如果您只需检索此处查询的 Customer 实体实例的 cust_name 字段的值,上面的代码片段应改为:
... Integer cust_id =2; String cust_name = (String)em.createQuery("SELECT c.cust_name FROM Customer c WHERE c.cust_id=:cust_id") .setParameter("cust_id", cust_id) .getSingleResult(); out.println("Customer with id "+cust_id+" is: "+cust_name+"
"); ...
同样,要获取客户名称的完整列表,可以使用以下代码:
... Listarr_cust_name = (List )em.createQuery("SELECT c.cust_name FROM Customer c") .getResultList(); out.println("List of all customers: "+"
"); Iterator i = arr_cust_name.iterator(); String cust_name; while (i.hasNext()) { cust_name = (String) i.next(); out.println(cust_name+"
"); } ...
重新再看 SQL,您可能想起 SQL 查询的选择列表可以由 FROM 子句中指定的表中的若干个字段组成。在 JPQL 中,您还可以使用组成的选择列表,仅从感兴趣的实体字段中选择数据。但是,在这种情况下,您需要创建一个类,将查询结果赋予该类。在下一小节中,您将看到一个 JPQL 联接查询的示例,该查询的选择列表由源自多个实体的字段组成。
与 SQL 类似,JPQL 允许您定义联接查询。然而在 SQL 中,您通常定义一个联接来组合来自两个或多个表和/或视图中的记录,仅将这些表和视图中的所需字段添加到联接查询的选择列表中。而 JPQL 联接查询的选择列表通常只包括一个实体甚至只包括一个实体字段。其原因在于 JPA 技术的实质。一旦您获取了一个实体实例,您就可以使用相应的 getter 方法导航到其相关的实例。使用该方法,您无需定义一次返回所有相关实体实例的查询。
例如,要在 SQL 中获取有关订单以及它们的行项目的信息,您需要在 purchaseOrders 和 orderLineItems 表上都定义一个联接查询,在查询的选择列表中指定这两个表中的字段。然而,使用 JPQL 时,您可以定义一个仅针对 PurchaseOrder 实体的查询,然后根据需要,使用 PurchaseOrder 的 getOrderLineItems 方法导航到相应的 OrderLineItem 实例。在本示例中,仅当您需要根据适用于 OrderLineItem 的条件筛选检索到的 PurchaseOrder 实例时,您可能才希望定义一个针对 PurchaseOrder 和 OrderLineItem 实体的 JPQL 查询。
以下片段显示一个实际的 JPQL 联接查询的示例。为了更好地了解所涉及的实体之间的相互关系,您可以回头查看本文前面的“示例应用程序”一节中的图 1。
... Double max = (Double) em.createQuery("SELECT MAX(p.price) FROM PurchaseOrder o JOIN o.orderLineItems l JOIN l.product p JOIN p.supplier s WHERE s.sup_name = 'Tortuga Trading'") .getSingleResult(); out.println("The highest price for an ordered product supplied by Tortuga Trading: "+ max + "
"); ...
在上面的示例中,您在联接查询的 SELECT 子句中使用了 MAX 聚合函数,以确定 Tortuga Trading 提供的至少被订购过一次的价格最高的产品。
然而更常见的情况是,当您需要进行计算,比如说,已订购的某个供应商提供的产品的总价格时。这时,SUM 聚合函数就派上用场了。在 SQL 中,这样的联接查询的代码可能如下:
SELECT SUM(p.price*l.quantity) FROM purchaseorders o JOIN orderlineitems l ON o.pono=l.pono JOIN products p ON l.prod_id=p.prod_id JOIN suppliers s ON p.sup_id=s.sup_id WHERE sup_name ='Tortuga Trading';
遗憾的是,JPQL 中使用的 SUM 函数不允许您将算术表达式作为参数值进行传递。这在实践中意味着,您将不能将 p.price*l.quantity 作为参数值传递给 JPQL 的 SUM。但是,有多种方法可以解决这个问题。在下面的示例中,您定义 LineItemSum 类,然后,在查询的选择列表中使用它的构造函数,将 p.price 和 l.quantity 作为参数。LineItemSum 构造函数的作用是将 p.price 乘以 l.quantity,将结果保存到它的 rslt 类变量。接下来,您可以迭代查询检索到的 LineItemSum 列表,计算 LineItemSum 的 rslt 变量值的总和。以下片段显示了实施这些操作的代码:
package jpqlexample.servlets; ... class LineItemSum { private Double price; private Integer quantity; private Double rslt; public LineItemSum (Double price, Integer quantity){ this.rslt = quantity*price; } public Double getRslt () { return this.rslt; } public void setRslt (Double rslt) { this.rslt = rslt; } } public class JpqlJoinsServlet extends HttpServlet { ... public void doGet( ... Listarr = (List )em.createQuery ("SELECT NEW jpqlexample.servlets.LineItemSum(p.price, l.quantity) FROM PurchaseOrder o JOIN o.orderLineItems l JOIN l.product p JOIN p.supplier s WHERE s.sup_name = 'Tortuga Trading'") .getResultList(); Iterator i = arr.iterator(); LineItemSum lineItemSum; Double sum = 0.0; while (i.hasNext()) { lineItemSum = (LineItemSum) i.next(); sum = sum + lineItemSum.getRslt(); } out.println("The total cost of the ordered products supplied by Tortuga Trading: "+ sum + "
"); } }
此外,上面的示例还说明了如何在 JPQL 查询的选择列表(包括源自多个实体的字段)中使用自定义 Java 类(而非实体类),并将查询的结果赋予该类。然而,在大多数情况下,您需要处理以下查询:检索某个实体的某个实例或实例列表。
到目前为止,只是将本文示例中的查询结果打印出来。但是,在实际应用程序中,您可能需要对查询结果执行一些进一步的操作。例如,您可能需要更新检索的实例,然后重新在数据库中持续保存它们。这就引出一个问题:JPQL 查询检索的实例是否已准备好接受应用程序进一步的处理,或者是否需要执行额外的步骤使这些实例做好准备?尤其是,需要了解在当前的持久性上下文中,检索的实体实例处于什么状态。
如果您对 Java 持久性有所了解,您应该知道什么是持久性上下文。简单地说,持久性上下文是由与该上下文相关联的 EntityManager 实例管理的一组实体实例。在前面的示例中,您使用 EntityManager 的 createQuery 方法来创建 Query 的实例以执行 JPQL 查询。实际上,EntityManager API 包括 20 多种方法,用于管理实体实例的生命周期、控制事务以及创建 Query(其方法然后用于执行指定的查询和检索查询结果)的实例。
在持久性上下文中,实体实例可以处于以下四种状态之一:新建、受控、分离或删除。使用相应 EntityManager 的方法,您可以根据需要更改特定实体实例的状态。但值得注意的是,刷新数据库时,只有处于受控状态的实例同步到数据库。准确地说,处于删除状态的实体实例也得到同步,即,与这些实例对应的数据库记录从数据库中删除。
而处于新建或分离状态的实例不会同步到数据库。例如,如果您新建一个 PurchaseOrder 实例,然后调用 EntityManager 的 flush 方法,另一个记录将出现在 purchaseOrders 表中,PurchaseOrder 实体映射到该表。这是因为新的 PurchaseOrder 实例尚未附加到持久性上下文。下面是这段代码:
... em.getTransaction().begin(); Customer cust = (Customer) em.find(Customer.class, 1); PurchaseOrder ord = new PurchaseOrder(); ord.setOrder_date(new Date()); ord.setCustomer(cust); em.getTransaction().commit(); ...
要解决此问题,您需要在调用 flush 前,针对新的 PurchaseOrder 实例调用 EntityManager 的 persist 方法,如以下示例所示:
... em.getTransaction().begin(); Customer cust = (Customer) em.find(Customer.class, 1); PurchaseOrder ord = new PurchaseOrder(); ord.setOrder_date(new Date()); ord.setCustomer(cust); em.persist(ord); em.getTransaction().commit(); ...
或者,如果您在 Customer 实体中定义与 PurchaseOrder 的关系时已将级联选项设为 PERSIST 或 ALL,您可以将新建的 PurchaseOrder 实例添加到与客户实例相关联的订单列表中,将 persist 操作替换为以下操作:
cust.getPurchaseOrders().add(ord);
上述有关实体实例状态的讨论引发了一个有趣的问题,JPQL 查询检索的实体实例是否自动变为受控状态,或者您是否需要费心将它们的状态显式设为受控。根据 JPA 规范,无论您使用何种方式检索实体(无论使用 EntityManager 的 find 方法还是查询),实体都会自动附加到当前的持久性上下文。这意味着 JPQL 查询检索的实体实例自动变为受控状态。例如,您可以更改检索到的实例的字段值,然后通过调用 EntityManager 的 flush 方法或提交当前的事务,将更改同步到数据库。您也无需考虑与检索到的实例相关联的实例的状态。事实是当您首次访问相关联的实例时,它自动变为受控状态。下面是一个简单的实例,显示了所有这一切的实际工作方式:
... em.getTransaction().begin(); PurchaseOrder ord = (PurchaseOrder)em.createQuery("SELECT o FROM PurchaseOrder o WHERE o.pono = 1") .getSingleResult(); Listitems = ord.getOrderLineItems(); Integer qnt = items.get(0).getQuantity(); out.println("Quantity of the first item : "+ qnt +"
"); items.get(0).setQuantity(qnt+1); qnt = items.get(0).getQuantity(); em.getTransaction().commit(); out.println("Quantity of the first item :"+ qnt +"
"); ...
注意,您没有针对检索到的 PurchaseOrder 实例调用 persist 方法,也没有对此处修改的与其相关联的 OrderLineItem 实例调用 persist 方法。尽管如此,对订单中第一个行项目所做的更改在提交事务时仍将在数据库中持续保存。这是因为检索到的实体实例与其关联项都自动附加到当前的持久性上下文。如前面所提到的,前者在被检索时自动变为受控状态,后者在您访问它们时附加到上下文。
在某些情况下,您可能希望关联项在查询执行时附加到上下文,而不是在首次访问时附加到上下文。这时 FETCH JOIN 就有用武之处了。比如说,您希望在检索某个客户实例时获取属于该客户的所有订单。该方法保证您处理的是在查询执行时可获得的客户订单。例如,如果在您首次访问与检索到的客户实例相关的订单列表之前,有一个新订单添加到另一个上下文中然后同步到数据库,在您刷新数据库中客户实例的状态之前,您将看不到该更改。在以下片段中,您使用联接查询返回 cust_id 为 1 的 Customer 实例,并获取与检索到的 Customer 实例相关联的 PurchaseOrder 实例。
... Customer cust = (Customer)em.createQuery("SELECT DISTINCT c FROM Customer c LEFT JOIN FETCH c.purchaseOrders WHERE c.cust_id=1") .getSingleResult(); ... Listorders = cust.getPurchaseOrders(); ...
作为显式查询结果的一部分,在查询执行时,与此处检索到的 Customer 实例相关联的 PurchaseOrder 实体实例也被检索到并附加当前的持久性上下文。
值得注意的是,定义要使用 Query API 执行的查询时,您不止限于 JPQL。您可能会感到惊讶,EntityManager API 提供了创建 Query 实例以执行原生 SQL 语句的方法。关于使用 EntityManager 方法创建的原生 SQL 查询需要了解的最重要的事是,它们与 JPQL 查询一样,返回实体实例而不是数据库表记录。下面是一个动态原生 SQL 查询的简单示例:
... Listcustomers = (List )em.createNativeQuery ("SELECT * FROM customers", jpqlexample.entities.Customer.class) .getResultList(); Iterator i = customers.iterator(); Customer cust; out.println("Customers: " + "
"); while (i.hasNext()) { cust = (Customer) i.next(); out.println(cust.getCust_name() +"
"); } ...
JPQL 仍处于发展中,不具备 SQL 提供的许多重要特性。在前面的“定义 JPQL 联接”部分中,您看到了 JPQL 的不完善之处的示例:您需要自己做大量的工作,因为 JPQL 的 SUM 聚合函数不能将算术表达式当作参数。而 SQL 的 SUM 函数则没有这样的限制。因此,该示例很好地说明了什么情况下将 JPQL 替换为原生 SQL 更高效。以下代码说明了如何通过选择原生 SQL 代替 JPQL 来简化该特定示例:
... String sup_name ="Tortuga Trading"; BigDecimal sum = (List)em.createNativeQuery("SELECT SUM(p.price*l.quantity) FROM orders o JOIN orderlineitems l ON o.pono=l.pono JOIN products p ON l.prod_id=p.prod_id JOIN suppliers s ON p.sup_id=s.sup_id WHERE sup_name =?1") .setParameter(1, sup_name) .getSingleResult(); out.println("The total cost of the ordered products supplied by Tortuga Trading: " + sum +"
"); ...
此外,上面的示例还说明了您可以将参数值绑定到原生查询参数。尤其是,您可以将参数值绑定到位置参数,与您处理 JPQL 查询的方式相同。
原生查询的缺点是结果绑定的复杂性。在本示例中,查询只生成了一个简单类型的结果,因此避免了此问题。但在实际中,您经常需要处理复杂类型的结果集。在这种情况下,您需要声明一个可以将您的原生查询映射到的实体,或者定义一个映射到多个实体或实体和标量结果的组合的复杂结果集。
原生查询的另一个缺点是,您的 Java 代码直接依赖于底层数据库结构。如果您修改该底层结构,您将需要在您的 servlet 和/或其他应用程序组件中调整相关的原生查询,然后再重新编译和部署这些组件。要解决此问题,同时仍然使用原生查询,您可以利用存储过程,将复杂的 SQL 查询移到在数据库内部存储和执行的程序中,然后调用这些存储的程序而非直接调用底层表。这在实践中意味着,存储过程可以免去直接从硬编码到您的 Java 代码中的查询处理底层表的麻烦。该方法的好处是,大多数情况下,您不必修改 Java 代码来顺应底层数据库结构中的更改,而只需修复存储过程。
重新回到上一小节中讨论的示例,您可以将其中使用的复杂联接查询移到一个存储函数中,创建如下:
CREATE OR REPLACE FUNCTION sum_total(supplier VARCHAR2) RETURN NUMBER AS sup_sum NUMBER; BEGIN SELECT SUM(p.price*l.quantity) INTO sup_sum FROM orders o JOIN orderlineitems l ON o.pono=l.pono JOIN products p ON l.prod_id=p.prod_id JOIN suppliers s ON p.sup_id=s.sup_id WHERE sup_name = supplier; RETURN sup_sum; END; /
这简化了在 Java 代码中使用的原生查询,并消除了对底层表的依赖性:
... String sup_name ="Tortuga Trading"; BigDecimal sum = (BigDecimal)em.createNativeQuery("SELECT sum_total(?1) FROM DUAL") .setParameter(1, sup_name) .getSingleResult(); out.println("The total cost of the ordered products supplied by Tortuga Trading: " + sum +"
"); ...
如您在本文中所了解到的,JPQL 是一个从使用 Java 持久性的 Java 应用程序内部访问关系数据的功能强大的工具。与 SQL/JDBC 不同,使用 JPQL 时,您是针对映射到底层表的 JPA 实体定义查询而不是直接查询这些表,因此从业务逻辑层处理隐藏数据库详细信息的抽象层。您还了解到 JPQL 不是针对 JPA 实体创建查询的唯一选择,在某些情况下,使用原生 SQL 查询更方便。
Yuli Vasiliev 是一名软件开发人员、自由撰稿人和顾问,目前专攻开源开发、Java 技术、数据库和 SOA。他撰写了《Beginning Database-Driven Application Development in Java EE:Using GlassFish》(Apress, 2008)。