
Recién vistos los fundamentos del lenguaje de consultas JPQL, y antes de seguir profundizando, veamos cómo materializar las consultas, simples cadenas de texto, en código Java que las ejecute.
Ejecución de consultas
Las consultas en JPA, con independencOtra posibilidadia de su tipo (JPQL, Criteria API, SQL) se configuran y ejecutan con las interfaces Query y TypedQuery<T>. La diferencia entre ellas es su tipado. Cuando sepamos de antemano el tipo del resultado -lo más habitual-, elegiremos TypedQuery para que el código sea más seguro y fácil de utilizar. En otro caso, recurrimos a Query para recoger los resultados en Object.
Los objetos de ambos tipos se crean con métodos del gestor de entidades. Empecemos considerando que la consulta JPQL o HQL -es indiferente- se declara de forma dinámica con una simple cadena de texto. En este caso, usaremos los métodos createQuery(String) para Query y createQuery(String, Class<T>) para TypedQuery. Una vez tengamos la consulta, la ejecutamos con uno de estos métodos, disponibles en las interfaces.
- getSingleResult. SELECT que devuelve un único resultado. Si retorna más de uno, se lanza la excepción NonUniqueResultException. Si no hay ninguno, lanza NoResultException.
- getResultList. SELECT que puede devolver más de un resultado en una lista (List), lo que garantiza el orden. Si no hay datos, estará vacía.
- getResultStream. Equivalente al anterior con un Stream.
- executeUpdate. Ejecuta una sentencia UPDATE, DELETE o, solo en HQL, INSERT.

¡Manos a la obra! En primer lugar creamos un DAO para la entidad Expense que contenga el método findAll. Debe devolver un listado con todos los gastos. Lo haremos en el nuevo proyecto de ejemplo jpa-query, similar a los de capítulos anteriores, y que iremos ampliando en los próximos capítulos. Probaremos los DAO con test de Arquillian y los juegos de datos ubicados en /src/test/resources/datasets/.

En findAll, crearemos un objeto TypedQuery con la consulta y llamaremos a getResultList.
package com.danielme.jakartaee.jpa.dao;
import com.danielme.jakartaee.jpa.entities.Expense;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
@ApplicationScoped
public class ExpenseJpqlDAOImpl extends GenericDAOImpl<Expense, Long> implements ExpenseJpqlDAO {
public ExpenseJpqlDAO() {
super(Expense.class);
}
@Override
public List<Expense> findAll() {
return em.createQuery("SELECT e FROM Expense e ORDER BY e.date DESC, e.concept",
Expense.class)
.getResultList();
}
}
No hay demasiado que comentar. Un detalle muy útil es que los métodos de Query\TypedQuery devuelven el propio objeto para poder encadenar llamadas. Por ello, en lugar de asignar el objeto TypedQuery<Expense> a una variable, le he solicitado que ejecute la consulta y devuelva los resultados en una lista.
Con Query, el código apenas cambia, pero se produce un «warning» debido a la ausencia de tipado de la lista.

Otra posibilidad es recoger el resultado en un Stream. El funcionamiento de getResultStream depende de cada proveedor. En Hibernate, su Stream va iterando en bloques sobre los datos del ResultSet de JDBC para evitar cargarlos todos, aunque el rendimiento de la operación está condicionado por las capacidades del controlador JDBC que usemos. De todos modos, el filtrado, la paginación y la ordenación de resultados debe hacerse en la consulta, nunca en código, por ser lo más eficiente. Las bases de datos están optimizadas para estas operaciones.
Lo siguiente es un buen ejemplo del mal uso de getResultStream. Funciona, pero no es aceptable bajo ningún concepto traer a nuestra aplicación todos los gastos desde la base de datos pese a que únicamente necesitamos los de hoy. Además, la consulta puede obtenerlos ya ordenados.
em.createQuery("SELECT e FROM Expense e", Expense.class)
.getResultStream()
.filter(e -> e.getDate().equals(LocalDate.now()))
.sorted(Comparator.comparing(Expense::getDate))
.collect(Collectors.toList())
¿Cuándo usamos el método getSingleResult? Según lo que indiqué, queda reservado para las situaciones en las que la consulta devuelve un resultado único que siempre existe, porque lo contrario causa el lanzamiento de una excepción. Un buen ejemplo de su uso es la recuperación de un conteo: COUNT retorna un Long que será cero si no hay datos. El siguiente método cuenta las entidades de un tipo que existen en la base de datos.
@Override
public Long count() {
return em.createQuery("SELECT COUNT(entity) FROM " + entityClass.getSimpleName() + " entity",
Long.class)
.getSingleResult();
}
El método count lo he codificado en la clase GenericDAOImpl, así que está disponible para todas las entidades con DAO.
Es importante subrayar que no es lo mismo que no haya resultado a que exista y sea nulo, circunstancia posible con otras funciones de JPQL. En estos casos, getSingleResult también puede invocarse con total seguridad, pues no fallará y devolverá un nulo. Es el caso de sumToday: suma los gastos (función SUM) del día de hoy (la fecha devuelta por la función CURRENT_DATE). En el juego de datos de prueba, todos los gastos tienen fechas pretéritas, y SUM devuelve NULL cuando no hay valores a sumar.
Nota: veremos el uso de funciones en consultas en el capítulo 35.
@Override
public BigDecimal sumToday() {
return em.createQuery("SELECT SUM(e.amount) FROM Expense e WHERE e.date = CURRENT_DATE",
BigDecimal.class)
.getSingleResult();
}
@Test
void testNoSumToday() {
BigDecimal sum = expenseJpqlDAO.sumToday();
assertThat(sum).isNull();
}
Vamos con el otro caso posible: el resultado es único y no sabemos de antemano si existe (cuando no exista, ni siquiera es NULL). Para el siguiente ejemplo, recordemos la relación one-to-one unidireccional entre Coupon y Expense.
@Override
public Optional<Expense> findByCouponId(Long couponId) {
try {
Expense expense = em.createQuery("SELECT c.expense FROM Coupon c WHERE c.id =:id",
Expense.class)
.setParameter("id", couponId)
.getSingleResult();
return Optional.of(expense);
} catch (NoResultException ex) {
return Optional.empty();
}
}
En findByCouponId se obtiene el posible gasto asociado a un cupón. El gasto solo existirá cuando el cupón, además de existir, se haya descontado a un gasto, así que es posible que la llamada a getSingleResult lance NoResultException. El método retorna Optional porque dota de una mayor semántica a su signatura: con verla, sabemos que el gasto puede que no exista. Y, por supuesto, Optional nos permite tratar el resultado de forma funcional si así lo deseamos.
Si bien el código anterior funciona, capturar la excepción es poco elegante. Mejor recoger el posible resultado en un listado o Stream y obtener su único elemento, si es que existe.
public Optional<Expense> findByCouponIdV2(Long couponId) {
return em.createQuery("SELECT c.expense FROM Coupon c WHERE c.id =:id",
Expense.class)
.setParameter("id", couponId)
.getResultStream()
.findFirst();
}
Las dos versiones del método findByCouponId verifican estas pruebas.
@Test
void testFindByCouponEmpty() {
Optional<Expense> expense = expenseJpqlDAO.findByCouponId(Datasets.RANDOM_ID);
assertThat(expense).isNotPresent();
}
@Test
void testFindByCoupon() {
Optional<Expense> expense = expenseJpqlDAO.findByCouponId(Datasets.COUPON_ID_SUPER);
assertThat(expense).isPresent();
}
Entidades de solo lectura
Reitero lo que expliqué en la sección ¿Entidad o DTO? del capítulo anterior. La recuperación de datos parciales de entidades es preferible hacerla en DTOs o records. Contamos para ello con varias opciones tanto en JPA como en Hibernate que examinaremos en la siguiente entrega del curso. No obstante, si optamos por emplear entidades, es posible indicar a Hibernate que ignore sus cambios. Lo vemos en el siguiente método, perteneciente a la nueva clase CategoryJpqlDAOImpl, que obtiene todas las categorías.
@Override
public List<Category> findAllAsReadOnly() {
return em.createQuery("SELECT c FROM Category c", Category.class)
.setHint(QueryHints.READ_ONLY, true)
.getResultList();
}
Los «hints» son un mecanismo de JPA que permite la creación de nuevas opciones para las consultas, por parte de las implementaciones o de la propia especificación, sin requerir la modificación de la API. Como apreciamos en el código, en la práctica un hint no es más que un par clave-valor proporcionado con el método setHint. De esta forma tan ingeniosa, podemos usar una propiedad de configuración de la consulta exclusiva de Hibernate desde JPA. Asimismo, si una implementación no contempla el hint, lo ignorará.
Las categorías devueltas por findAllAsReadOnly se registran en la caché de primer nivel y podemos modificarlas invocando a sus setters, pero sus cambios serán ignorados cuando el contexto de persistencia se sincronice con la base de datos. Además de seguridad frente a modificaciones indeseadas, lo más interesante es que también ganamos una pequeña mejora de rendimiento, pues Hibernate no tiene que comprobar si se realizaron cambios en las entidades.
Verifiquemos que la activación de QueryHints.READ_ONLY cumple lo prometido.
@Test
void testFindAllReadOnly() {
Category categoryReadOnly = categoryJpqlDAO.findAllAsReadOnly().get(0);
categoryReadOnly.setName("new name");
em.flush();
em.detach(categoryReadOnly);
Category categoryDb = categoryJpqlDAO.findById(categoryReadOnly.getId()).get();
assertThat(categoryDb.getName())
.isNotEqualTo(categoryReadOnly.getName());
}
Estamos modificando una categoría (línea 5) obtenida en modo solo lectura (línea 3). La llamada a flush (línea 6) no debe volcar los cambios en la tabla, por lo que si recuperamos la misma categoría desde la base de datos (línea 9) (*), su nombre será el original.
(*) Esta nueva carga la forzamos poniendo categoryReadOnly en modo detached o desligado para borrarla de la caché. Ya hicimos esto en otros ejemplos del curso. Como alternativa, se puede recargar la entidad con los datos actualmente en la tabla invocando al método refresh del gestor de entidades.
Los hints también son aplicables a las entidades obtenidas con find si usamos la sobrecarga del método que recibe como último parámetro un Map.
Map<String, Object> hints = new HashMap<>();
hints.put(QueryHints.READ_ONLY, true);
Category category = em.find(Category.class, id, hints);
Parámetros
Los parámetros o variables de entrada a reemplazar en tiempo de ejecución en las consultas nunca los «pegaremos» manualmente. Además de ser una «chapucilla», requiriendo a veces el tratamiento de nulos y conversiones a cadena, resulta peligroso porque quedamos expuestos a ataques de inyección de SQL en el momento en que el valor sea un texto proporcionado desde fuera del sistema, por ejemplo, a través de un formulario web.
Debemos usar la técnica presentada en el capítulo anterior y empleada líneas atrás en el método findByCouponId.
Expense expense = em.createQuery("SELECT c.expense FROM Coupon c WHERE c.id =:id", Expense.class)
.setParameter("id", couponId)
.getSingleResult();
Cada parámetro distinto se declara dentro de la consulta identificándolo con un nombre único precedido de dos puntos. Le daremos valor con uno de los métodos setParameter de Query. Su primer argumento es el identificador de la variable. Todos los parámetros proporcionados deben aparecer en la consulta y viceversa, si no fuera así veremos excepciones del tipo IllegalArgumentException.
Alternativamente, vimos que los parámetros también son identificables con un índice (?1, ?2…). Este método busca los gastos realizados entre dos fechas.
@Override
public List<Expense> findByDateRange(LocalDate dateFrom, LocalDate dateTo) {
return em.createQuery("SELECT e FROM Expense e WHERE e.date BETWEEN ?1 AND ?2 ORDER BY e.date DESC, e.amount DESC", Expense.class)
.setParameter(1, dateFrom)
.setParameter(2, dateTo)
.getResultList();
}
Esta sintaxis me parece menos legible. De todas maneras, cualquiera de las dos opciones es más práctica que las variables nombradas con ? en las consultas SQL de JDBC. Algo que, por cierto, no podemos hacer en JPQL.
Los parámetros son reutilizables y se proporcionan una vez. En el siguiente ejemplo, text aparece en dos expresiones condicionales que seleccionan los gastos en función de su concepto o comentarios.
@Override
public List<Expense> findByText(String text) {
return em.createQuery("SELECT e FROM Expense e WHERE UPPER(e.concept) LIKE UPPER (:text) OR UPPER(e.comments) LIKE UPPER(:text)", Expense.class)
.setParameter("text", text)
.getResultList();
}
Un caso interesante son las cláusulas IN cuando su argumento, un conjunto de valores, es variable y, por tanto, debe ser un parámetro. En JDBC lo más seguro y portable es unir los valores en una cadena, de lo contrario hay que recurrir a características específicas del driver. En JPA, suministramos una lista y a correr. Sirva de ejemplo el método findByCategoryIds que obtiene los gastos pertenecientes a cualquiera de las categorías indicadas. Si la lista de identificadores fuera vacía o nula, no se devolverán gastos.
@Override
public List<Expense> findByCategoryIds(List<Long> ids) {
return em.createQuery("SELECT e FROM Expense e WHERE e.category.id IN :ids ORDER BY e.concept",
Expense.class)
.setParameter("ids", ids)
.getResultList();
}
@Test
void testFindByCategoryIds() {
List<Expense> expenses = expenseJpqlDAO.findByCategoryIds(
List.of(Datasets.CATEGORY_ID_FOOD, Datasets.CATEGORY_ID_FUEL));
assertThat(expenses)
.extracting(Expense::getId)
.containsExactly(Datasets.EXPENSE_ID_1, Datasets.EXPENSE_ID_2);
}
Filtrado dinámico
Por lo general, los filtros de búsqueda suelen considerarse opcionales. Es decir, si no se provee el valor por el que realizar la selección de los datos, no se aplica el filtrado. No es lo que hace findByDateRange: si alguna de las fechas es nula, la consulta nunca devolverá resultados porque la condición del WHERE es imposible de cumplir (los gastos no pueden tener la fecha nula). Por consiguiente, esta prueba será exitosa.
@Test
void testFindByDateRangeBothNull() {
List<Expense> expenses = expenseJpqlDAO.findByDateRange(null, null);
assertThat(expenses).isEmpty();
}
Si queremos que ambos valores del rango de fecha solo se tengan en cuenta, de forma independiente, cuando no sean nulos, podemos ingeniárnoslas y dar con este WHERE.
SELECT e
FROM Expense e
WHERE (?1 IS NULL OR e.date >= ?1)
AND (?2 IS NULL OR e.date <= ?2)
ORDER BY e.date DESC, e.amount DESC
Ahora, los parámetros con las fechas solo se usan si no son nulos. Es una consulta extraña, pero funciona, tal y como acreditan esta colección de pruebas.
@Test
void testFindByDateRangeBothNull() {
List<Expense> expenses = expenseJpqlDAO.findByDateRange(null, null);
assertThat(expenses).hasSize(Datasets.TOTAL_EXPENSES);
}
@Test
void testFindByDateRangeBefore() {
LocalDate to = LocalDate.of(2021, 7, 20);
List<Expense> expenses = expenseJpqlDAO.findByDateRange(null, to);
assertThat(expenses).extracting(Expense::getId).containsExactly(Datasets.EXPENSE_ID_2,
Datasets.EXPENSE_ID_1);
}
@Test
void testFindByDateRangeAfter() {
LocalDate from = LocalDate.of(2021, 8, 1);
List<Expense> expenses = expenseJpqlDAO.findByDateRange(from, null);
assertThat(expenses).extracting(Expense::getId).containsExactly(Datasets.EXPENSE_ID_3,
Datasets.EXPENSE_ID_4);
}
@Test
void testFindByDateRangeInside() {
LocalDate from = LocalDate.of(2021, 6, 5);
LocalDate to = LocalDate.of(2021, 7, 20);
List<Expense> expenses = expenseJpqlDAO.findByDateRange(from, to);
assertThat(expenses).extracting(Expense::getId).containsExactly(Datasets.EXPENSE_ID_2,
Datasets.EXPENSE_ID_1);
}
Puesto que la consulta es una cadena de texto, es posible construir en tiempo de ejecución las condiciones que sean necesarias. La consulta resultante será más «natural», por decirlo de algún modo, pero tendremos que construirla y lidiar con el inconveniente de establecer solo los parámetros que sean necesarios.
public List<Expense> findByDateRangeV2(LocalDate dateFrom, LocalDate dateTo) {
StringBuilder jpql = new StringBuilder("SELECT e FROM Expense e WHERE 1 = 1 ");
if (dateFrom != null) {
jpql.append(" AND e.date >= ?1 ");
}
if (dateTo != null) {
jpql.append(" AND e.date <= ?2 ");
}
jpql.append("ORDER BY e.date DESC, e.amount DESC");
TypedQuery<Expense> query = em.createQuery(jpql.toString(), Expense.class);
if (dateFrom != null) {
query.setParameter(1, dateFrom);
}
if (dateTo != null) {
query.setParameter(2, dateTo);
}
return query.getResultList();
}
Mucho código y poco vistoso. Además, se trata de un ejemplo sencillo; no será raro tener más condiciones e incluso criterios de ordenación dinámicos. Ante este panorama, resulta más elegante y seguro, aunque tal vez menos legible, evitar concatenar cadenas y valerse de Criteria API. Nos ponderemos a ello en un par de capítulos.
Consultas nombradas
Las consultas nombradas son consultas a las que asignamos un nombre único dentro de cada contexto de persistencia y que usamos con los métodos createNamedQuery del gestor de entidades. La convención habitual es añadir como prefijo de ese nombre el de la entidad «principal» de la consulta. En lo que respecta a su empleo, no hay ninguna diferencia con las que hemos visto hasta el momento. Si pedimos una que no existe, se lanza IllegalArgumentException.
@Override
public List<Category> findByName(String name) {
return em.createNamedQuery("Category.findByName", Category.class)
.setParameter("name", name == null ? "%" : "%" + name + "%")
.getResultList();
}
Lo interesante de findByName es lo que no vemos en el método anterior: la declaración de la consulta llamada Category.findByName. Hay tres maneras de hacerlo, proporcionando siempre la consulta y su nombre único. Opcionalmente, se indicarán los hints a aplicar.
- En el fichero estándar /META-INF/orm.xml
@Entity
@Table(name = "categories")
@Getter
@Setter
@NamedQuery(name = "Category.findByName",
query = "SELECT c FROM Category c WHERE c.name LIKE :name",
hints = {@QueryHint(name = QueryHints.READ_ONLY, value = "true"))
public class Category {
- En el fichero estándar /META-INF/orm.xml. No olvidar incluirlo en el objeto WebArchive que despliega Arquillian si lo necesitamos en las pruebas.
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings version="3.0" xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd">
<named-query name="Category.findAll">
<query>SELECT c FROM Category c ORDER BY c.name</query>
<hint name="org.hibernate.readOnly" value="true"/>
</named-query>
</entity-mappings>
- En tiempo de ejecución, lo que permite la construcción dinámica de la cadena con la consulta. Una vez que se añade al contexto de persistencia, queda disponible como cualquier otra consulta nombrada y no puede modificarse.
@Test
void testDynamicNamedQuery() {
Query query = em.createQuery("SELECT c FROM Category c WHERE c.id = :id");
em.getEntityManagerFactory().addNamedQuery("Category.findById", query);
@SuppressWarnings("JpaQueryApiInspection")
Category category = em.createNamedQuery("Category.findById", Category.class)
.setParameter("id", Datasets.CATEGORY_ID_FOOD)
.getSingleResult();
assertThat(category.getId())
.isEqualTo(Datasets.CATEGORY_ID_FOOD);
}
La ventaja obvia de las consultas nombradas es su reusabilidad, aunque no es gran cosa porque lo habitual será que una consulta solo se use en un método de un DAO. Además, es algo que podemos hacer mediante constantes. Pero obtenemos dos beneficios adicionales si se declaran de forma estática.
- Las consultas quedan separadas del código Java siguiendo un criterio consistente (en las entidades o el fichero orm.xml).
- Hibernate las procesa cuando arranca para registrarlas en la unidad de persistencia. Si una consulta es sintácticamente errónea, se lanzará una excepción que abortará el despliegue de la aplicación.
jakarta.persistence.PersistenceException: [PersistenceUnit: persistenceUnit-personalBudget] Unable to build Hibernate SessionFactory Caused by: jakarta.persistence.PersistenceException: [PersistenceUnit: persistenceUnit-personalBudget] Unable to build Hibernate SessionFactory Caused by: org.hibernate.HibernateException: Errors in named queries:
Así pues, tenemos la certeza de que todas las consultas nombradas son válidas si Hibernate inició sin problemas. Una consulta que funciona puede dejar de hacerlo porque formateamos su cadena y se nos escapa algún espacio en blanco (estas cosas pasan), o bien no se tuvo en cuenta una refactorización de las entidades. En cualquier caso, la detección de estos errores debería ser responsabilidad de las pruebas automáticas.
Modificación de datos
Dediquemos un pequeño espacio a las operaciones de modificaciones de datos: las actualizaciones, borrados e inserciones masivas. Son consultas utilizables como las de tipo SELECT, con la particularidad de que nunca son tipadas y se ejecutan invocando al método executeUpdate. Devuelve un entero con el número de entidades afectadas por la operación.
Esta es la traslación a Java de la actualización masiva que vimos al final del capítulo anterior. Pone un prefijo en el nombre de los cupones que se hayan usado en un gasto.
@Override
public int updateUsedPrefixBulk() {
return em.createQuery("UPDATE Coupon c SET c.name = CONCAT(:prefix, c.name)
WHERE c.expense IS NOT NULL AND SUBSTRING(c.name, 1, :length) NOT LIKE :prefix")
.setParameter("length", Coupon.USED_PREFIX.length())
.setParameter("prefix", Coupon.USED_PREFIX)
.executeUpdate();
}
En coupons.yml tenemos dos cupones, uno de ellos asociado a su gasto. Será el que actualice la prueba testBulkUpdate.
@Test
void testBulkUpdate() {
int updated = couponJpqlDAO.updateUsedPrefixBulk();
assertThat(updated).isEqualTo(1);
}
He aquí un ejemplo de borrado. Elimina los cupones no gastados y caducados.
@Override
public int deleteExpired() {
return em.createQuery("DELETE FROM Coupon c WHERE c.expiration < CURRENT_DATE " +
AND c.expense IS NULL")
.executeUpdate();
}
Aprovechemos para demostrar la problemática de este tipo de operaciones: no actualizan o eliminan las entidades que ya sean parte del contexto de persistencia.
@Test
void testBulkUpdateBudgetAlreadyLoaded() {
Coupon coupon = couponJpqlDAO.findById(Datasets.COUPON_ID_SUPER).get();
assertThat(coupon.getName())
.doesNotStartWith(Coupon.USED_PREFIX);
couponJpqlDAO.updateUsedPrefixBulk();
assertThat(coupon.getName())
.doesNotStartWith(Coupon.USED_PREFIX);
}
Aun cuando el nombre cupón con el identificador 2 ha sido modificado por updateUsedPrefixBulk, la entidad coupon que lo representa no varía. Solo tendremos la entidad con el valor actualizado si la introducimos por primera vez en el contexto de persistencia después del UPDATE. Es lo que hace está prueba.
@Test
void testBulkUpdateBudgetNotLoaded() {
couponJpqlDAO.updateUsedPrefixBulk();
Coupon coupon = couponJpqlDAO.findById(Datasets.COUPON_ID_SUPER).get();
assertThat(coupon.getName())
.startsWith(Coupon.USED_PREFIX);
}
Podemos llamar al método refresh del gestor de entidades para que recarge la entidad con el contenido actual en la base de datos.
@Test
void testBulkUpdateBudgetRefresh() {
Coupon coupon = couponJpqlDAO.findById(Datasets.COUPON_ID_SUPER).get();
assertThat(coupon.getName())
.doesNotStartWith(Coupon.USED_PREFIX);
couponJpqlDAO.updateUsedPrefixBulk();
couponJpqlDAO.refresh(coupon);
assertThat(coupon.getName())
.startsWith(Coupon.USED_PREFIX);
}
Veamos en acción la inserción. Recordemos que es una operación exclusiva de HQL y que inserta los resultados de una SELECT. Por ejemplo, clonemos un gasto asignando al nuevo la fecha actual.
@Override
public void cloneForToday(Long id) {
em.createQuery("INSERT INTO Expense(amount, concept, date, category) " +
" SELECT e.amount, e.concept, :date, e.category " +
" FROM Expense e " +
" WHERE e.id = :id")
.setParameter("date", LocalDate.now())
.setParameter("id", id)
.executeUpdate();
}
@Test
void testCloneExpenseForToday() {
expenseJpqlDAO.cloneForToday(Datasets.EXPENSE_ID_1);
BigDecimal amount = expenseJpqlDAO.findById(Datasets.EXPENSE_ID_1).get().getAmount();
assertThat(expenseJpqlDAO.sumToday())
.isEqualTo(amount);
}
Código de ejemplo
El código de ejemplo se encuentra en GitHub (todos los proyectos son independientes pero están en un único repositorio). Para más información sobre cómo utilizar GitHub, consultar este artículo.