Curso Jakarta EE 9 (23). JPA con Hibernate (6): contexto de persistencia y gestor de entidades.

logo Jakarta EE

Tenemos las entidades, una fuente de datos MySQL en bWildFly y Jakarta Persistence\JPA debidamente configurado con Hibernate ORM como proveedor. ¿Cómo interactuamos con JPA en nuestro código para trabajar con las entidades?

>>>> ÍNDICE <<<<

El contexto de persistencia

La respuesta reside en un concepto capital de JPA. Un contexto de persistencia, también llamado sesión en Hibernate, es un contenedor, en la línea de CDI, de instancias de entidades que están sincronizadas con la base de datos y que poseen un «ciclo de vida» bien definido. Los cambios en estos objetos se vuelcan en las tablas con sentencias SQL generadas automáticamente, y solo hay un objeto (entidad) por cada registro de la base de datos que se haya importado o creado en el contexto.

Los contextos se construyen partiendo de la configuración de una unidad de persistencia declarada en el persistence.xml. De forma predeterminada, funcionan en modo transaccional (Transaction-Scoped Persistence Context). Se crean al iniciarse la ejecución de un conjunto de métodos englobados bajo una misma transacción, y se cierran al finalizarse, por lo que la vida de un contexto es efímera. Su destrucción causa, grosso modo, la sincronización entre entidades y tablas cuando sea necesario.

El otro modo de funcionamiento se denomina extendido (Extended Persistence Context). Su empleo es complejo y poco habitual. Al menos yo, no recuerdo haberlo visto nunca. No lo trataremos en el curso.

Dentro de un contexto de persistencia, los objetos que representan a las entidades se reutilizan para evitar llamadas innecesarias a la base de datos y facilitar el seguimiento de los cambios. La consecuencia en nuestro código es que veremos que una entidad siempre está representada en un contexto por el mismo objeto, hecho que verificaremos un poco más adelante con un test. Esto se consigue con una caché de entidades, llamada «caché de primer nivel«, elemento interno de Hibernate que no tenemos que configurar. Cada contexto tiene su propia caché de primer nivel de uso exclusivo que garantiza el aislamiento entre contextos \ sesiones \ transacciones.

El concepto «primer nivel» sugiere la posible existencia de otros niveles. Efectivamente, existe una caché de «segundo nivel» compartida por todos los contextos. A diferencia de la anterior, está caché es de uso opcional y requiere de ser configurada.

La caché de segundo nivel puede mejorar el rendimiento de nuestras aplicaciones almacenando entidades en memoria y, así, minimizar las llamadas a la base de datos. Pero solo es recomendable cuando tratemos con datos que se usen con gran frecuencia y que no suelan modificarse porque mantener los datos actualizados supone un coste y corremos el riesgo de trabajar con información obsoleta. En cualquier caso, la configuración adecuada de esta caché es un tema avanzado que no trataré en el curso. El lector puede encontrar información básica al final de este tutorial.

Transacciones

En el capítulo 19 configuramos JPA para que usara el sistema de gestión de transacciones que ofrece el servidor de aplicaciones mediante la especificación Jakarta Transactions (JTA). El estudio de JTA es un tema avanzado que queda fuera del alcance del curso. Para nuestros propósitos, será suficiente con un breve resumen de lo más básico, considerando además que solo queremos las transacciones para operar con una única fuente de datos SQL.

La manera más simple de crear transacciones consiste en anotar aquellos métodos que deban ejecutarse en una transacción con @Transactional. Esto es válido para cualquier CDI Bean. También puede anotarse toda la clase.

@Transactional
public void doInTransaction() {

Las transacciones pueden ser de varios tipos, definidos en el enumerado TxType. En la mayoría de situaciones, nos sirve con el predeterminado (TxType.REQUIRED). Si el método no se ha invocado dentro de una transacción, se creará una nueva y se ejecutará dentro de ella en su totalidad. Si el método se invocó dentro de una, formará parte de la misma. Esto significa que si durante la ejecución de un método transaccional se van invocando a otros métodos, todos se ejecutarán en la misma transacción si no están marcados con @Transactional, o bien lo están y son de tipo REQUIRED. Todo muy conveniente.

Si no hay problemas, la finalización de la transacción provocará la realización del COMMIT sobre la base de datos. La transacción se abortará con el correspondiente rollback que descartará todos los cambios cuando se produzca una excepción «unchecked». Son las que no estamos obligados a capturar por ser de tipo RuntimeException (NullPointerException, IllegalArgumentException, etc). En el caso de las demás (las «checked», declaradas en la signatura de los métodos), el rollback solo se hará para las indicamos en la propiedad rollbackOn.

@Transactional(rollbackOn = IOException.class)
public void doInTransaction() throws IOException {

Por comodidad, a veces se define rollbackOn para todas, especialmente si aplicamos la anotación a una clase.

@Transactional(rollbackOn = Exception.class)
public class SomeRandomService {

¿Cuándo necesitamos hacer transaccional un método? JPA exige métodos transaccionales para realizar operaciones de escritura en la base de datos. De lo contrario, se lanza la excepción TransactionRequiredException.

jakarta.persistence.TransactionRequiredException: WFLYJPA0060: Transaction is required to perform this operation (either use a transaction or extended persistence context)

En las operaciones de lectura no son necesarios; cada una de ellas lleva implícita la creación de una transacción propia (este comportamiento también lo tiene el uso directo de JDBC). En cualquier caso, si queremos trabajar con el mismo contexto de persistencia para realizar varias operaciones con él, recordemos que un contexto está asociado con una transacción, así que necesitamos que todo el código sea parte de uno o varios métodos ejecutados bajo una misma transacción.

Ciclo de vida

Las instancias de entidades pueden tener cuatro estados desde el punto de vista del contexto de persistencia.

  • Nuevo (Transient). Se ha instanciado una entidad nueva. Nunca ha formado parte de un contexto y no tiene vinculación alguna con la base de datos.
  • Persistente\gestionado (Persistent, Managed). La entidad forma parte del contexto y es gestionada por el mismo, lo que implica que sus cambios se propagarán a la base de datos bajo ciertas circunstancias. Puede tratarse de un objeto transient que hayamos añadido al contexto, o bien uno creado por JPA para importar un registro de la base de datos.
  • Desligado (Detached). La entidad ya no está en el contexto. En consecuencia, sus cambios no se sincronizarán con la base de datos. Todas las entidades quedan desligadas cuando se cierra el contexto.
  • Eliminada (Removed). Se trata de una entidad que existía en el contexto, pero que ha sido designada para ser borrada.

Esta es mi versión del típico diagrama que refleja los estados anteriores y sus transiciones, así como los métodos de JPA para realizarlas. En breve usaremos estos métodos para ilustrar de forma práctica el funcionamiento de todo el flujo.

El gestor de entidades

La interacción con un contexto de persistencia la proporciona el «gestor de entidades» asociado a la misma. Consiste en una interfaz llamada EntityManager que ya hemos empleado en otros capítulos. Con esta API se obtienen, añaden, modifican y eliminan objetos del contexto, y la «magia» del proveedor de JPA, en nuestro caso Hibernate, propaga esos cambios hacia la base de datos. Tenemos objetos y métodos, en lugar de SQL y llamadas a la API JDBC. Además, EntityManager también permite ejecutar consultas escritas con JPQL, SQL o la API Criteria.

En un servidor de aplicaciones, la instancia de EntityManager es creada e inyectada por el servidor cuando lo solicitemos con la anotación @PersistenceContext, así que no tenemos que preocuparnos demasiado por ello toda vez que hayamos configurado JPA.

@PersistenceContext
private EntityManager em;

En Hibernate, tenemos la siguiente jerarquía.

La clase SessionImpl implementa el gestor de entidades, pero también Session, la API propia de este marco de trabajo que ofrece funcionalidades similares a las del estándar, además de otras exclusivas. Si el lector ha seguido el curso, al menos en lo que respecta a JPA, ya sabe cómo incluir las librerías de Hibernate, acceder a Session para liberar todo su poder y usar el gestor de entidades en pruebas de JUnit 5 con Arquillian y Docker.

Veamos con detenimiento los principales métodos con los que contamos en EntityManager y que nos permitirán comprender el ciclo de vida de las entidades en el contexto o sesión.

Nuevas entidades

Empecemos con un caso de uso sencillo para ver cómo se utiliza el gestor de entidades. Lo implementaremos con una prueba. Pero antes, necesitamos la siguiente dependencia para usar transacciones en Arquillian.

        <dependency>
            <groupId>org.jboss.arquillian.extension</groupId>
            <artifactId>arquillian-transaction-jta</artifactId>
            <version>${arquillian.transaction.version}</version>
            <scope>test</scope>
        </dependency>

También se requiere que la fuente de datos sea de tipo JTA (auto gestionadas por el servidor de aplicaciones), no nos sirve RESOURCE_LOCAL.

<persistence-unit name="persistenceUnit-personalBudget" transaction-type="JTA">
        <jta-data-source>jdbc/personalBudgetDS</jta-data-source>

En las pruebas, usamos la anotación org.jboss.arquillian.transaction.api.annotation.Transactional. No confundir con jakarta.transaction.Transactional, aunque su propósito es el mismos: hacer que los métodos sean transaccionales. Así, dentro de una transacción, con el gestor de entidades inyectado se accede siempre al mismo contexto de persistencia.

Ahora sí, esta es la primera versión de la clase JpaLifeCycleArquillianTest.

package com.danielme.jakartaee.jpa;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.arquillian.transaction.api.annotation.Transactional;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;


@ExtendWith(ArquillianExtension.class)
@Transactional
public class JpaLifeCycleArquillianTest {

    @PersistenceContext
    private EntityManager em;

    @Deployment
    public static WebArchive createDeployment() {
        return Deployments.jpaLifecycle();
    }

    @Test
    void testPersist() {

    }

}

Vamos a escribir el método testPersist() paso a paso. Comenzamos creamos un nuevo gasto. Se trata, por tanto, de una entidad transient.

 Expense expense = new Expense();
 expense.setAmount(new BigDecimal("9.99"));
 expense.setConcept("Lifecycle test");
 expense.setDate(LocalDate.now());

Lo primero que haremos es incluirlo en el contexto invocando al método persist. Ahora, la entidad representada por el objeto se encuentra en estado managed y ya dispone de su identificador\clave primaria, requisito imprescindible para que una entidad forme parte del contexto.

 em.persist(expense);
 assertThat(expense.getId()).isNotNull();

Debido a que el identificador es de tipo identidad, Hibernate solo puede obtener su valor después de haber ejecutado el INSERT correspondiente a la entidad en la base de datos, tal y como ya expliqué. En consecuencia, veremos en la bitácora que la invocación de persist provoca la ejecución de la siguiente sentencia SQL.

insert into expenses (amount, comments, concept, date) values (?, ?, ?, ?)

Con cualquier otro tipo de identificador (manual, tabla, secuencia), Hibernate conoce su valor de antemano, así que la inserción no se lanzará hasta la invocación de flush, método que provoca la sincronización inmediata del contexto con la base de datos. Cuando esto ocurra, todos los cambios en las entidades que todavía no se hayan realizado en la base de datos se volcarán con las sentencias INSERT, UPDATE y DELETE apropiadas.

Al trabajar con @Transactional, será raro que llamemos a flush porque la sincronización ocurre automáticamente al finalizar la ejecución del método transaccional. Por cuestiones de eficiencia, Hibernate intenta retrasar lo máximo posible la sincronización, aunque puede realizarla cuando lo considere imprescidible para garantizar la coherencia de los datos. En los tests del curso, flush se utiliza para probar ciertos conceptos.

Que la inserción no se pueda demorar hasta el flush del contexto tiene una implicación importante en el rendimiento que también comenté cuando hablamos de los identificadores de tipo identidad: estas entidades no se pueden insertar en lote (batch) porque se van persistiendo de una en una a medida que se incorporan al contexto.

Volvamos a nuestro test. Recuperemos el nuevo gasto con find y comprobamos que Hibernate devuelve el mismo objeto managed que ya existe en el contexto. Se obtiene de la caché de primer nivel, y find no tiene que realizar una consulta SELECT.

Expense expenseInContext = em.find(Expense.class, expense.getId());
assertThat(expenseInContext).isEqualTo(expense);

¿Qué pasa si modificamos el concepto del gasto? Los cambios se persistirán cuando se realice el flush.

expense.setConcept("entity is managed");
em.flush();

Dado que expense ya se creo en la tabla previamente con un INSERT, Hibernate ejecutará un UPDATE.

update expenses set amount=?, comments=?, concept=?, date=? where id=?
Detach

La actualización anterior será válida mientras el gasto permanezca en estado managed. Si desligamos un objeto del contexto con detach, sus cambios ya no se sincronizarán. Es más, si en el momento de invocar a detach existieran cambios pendientes de ser persistidos, también se descartarán.

Probémoslo con un nuevo test.

@Test
@DataSet(value = "/datasets/expenses.yml")
void testDetach() {
    Expense expenseDetached = em.find(Expense.class, Datasets.EXPENSE_ID);
    expenseDetached.setConcept("detached!!");
    em.detach(expenseDetached);
    em.flush();

    Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID);
    assertThat(expenseDetached).isNotEqualTo(expense);
    assertThat(expense.getConcept()).isEqualTo("Lunch menu");
}

Después de desligar expenseDetached, podemos comprobar en la bitácora del servidor que flush no lanzará la sentencia UPDATE para modificar el concepto. Y si obtenemos la entidad otra vez con find, conseguimos un objeto managed distinto al desligado y con los datos esperados (los de la base de datos), tal y como verifican las tres últimas líneas.

Con clear, se desligan todas las entidades.

em.clear();
Remove

Este método borra del contexto una entidad managed (quedará en estado desligado) y se borrará de la tabla con la sentencia DELETE correspondiente.

delete from expenses where id=?
@Test
@DataSet(value = "/datasets/expenses.yml")
void testRemove() {
    Expense expenseRemove = em.find(Expense.class, Datasets.EXPENSE_ID);
    em.remove(expenseRemove);
    assertThat(em.find(Expense.class, Datasets.EXPENSE_ID)).isNull();
    em.flush();
}

La línea 6 demuestra que el gasto con identificador 1 ya no existe para el contexto, a pesar de que todavía no se ha ejecutado el DELETE. Tengamos en cuenta que hasta que llegue ese momento, el registro seguirá en la tabla y la entidad se podrá utilizar en otros contextos de persistencia.

Refresh

El «refresco» de una entidad consiste en la recarga de sus propiedades con los valores actuales en la base de datos. Esta acción implica que los posibles cambios que hayamos realizado en el objeto y todavía no se hayan persistido se perderán. Su uso es muy poco habitual, pues lo normal es que los mecanismos de sincronización del proveedor de JPA nos sirvan si sabemos usarlos de forma adecuada. Al final del capítulo 32 veremos un ejemplo.

Merge

Veamos ahora cómo podemos actualizar una entidad ya existente en la base de datos. Según demuestran los ejemplos anteriores (testPersist), si se encuentra en estado managed y la modificamos, los cambios se persistirán cuando se haga el flush. Pero también tenemos la opción de incluir en el contexto una entidad «detached» gracias el método merge, el cual devuelve un objeto de tipo managed equivalente al objeto detached que recibe como parámetro. ¿Por qué querríamos hacer esto? Parece innecesario disponiendo de find, pero muchas veces se da el caso de que la entidad desligada contiene cambios que queremos guardar.

@Test
@DataSet(value = "/datasets/expenses.yml")
void testMergeDetached() {
    Expense expenseDetached = em.find(Expense.class, Datasets.EXPENSE_ID);
    em.detach(expenseDetached);

    expenseDetached.setConcept("merged!!");
    Expense expenseManaged = em.merge(expenseDetached);
    assertThat(expenseManaged).isNotEqualTo(expenseDetached);
    assertThat(expenseManaged.getConcept()).isEqualTo(expense.getConcept());
    em.flush();
}

La llamada a merge devuelve un objeto managed distinto al desligado pero con el mismo contenido, tal y como comprueban las líneas 10 y 11. Al hacer flush, los cambios en la entidad managed, y, atención, que fueron realizados en el objeto desligado, se persistirán con el consabido UPDATE.

Si revisamos el log, vemos un detalle interesante: merge ha ejecutado una SELECT para obtener la entidad de la base de datos.

select
	expense0_.id as id1_0_0_,
	expense0_.amount as amount2_0_0_,
	expense0_.comments as comments3_0_0_,
	expense0_.concept as concept4_0_0_,
	expense0_.date as date5_0_0_
from
	expenses expense0_
where
	expense0_.id =?

La consulta no se realizará si la entidad está en el contexto porque merge devolverá el objeto managed ya existente. Tampoco se ejecutará si la entidad no tiene identificador y es de tipo autogenerado, porque esto implica que no existe en la base de datos el registro correspondiente e Hibernate lo sabe de antemano. No se pierde el tiempo en una consulta que no devolverá nada.

Otra prueba interesante. Obsérvese que expense y expenseDetached, ambos con el mismo identificador, dentro del contexto son representados por el mismo objeto.

@Test
@DataSet(value = "/datasets/expenses.yml")
void testMerge() {
    Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID);
    Expense expenseDetached = new Expense();
    expenseDetached.setId(Datasets.EXPENSE_ID);

    Expense expenseManaged = em.merge(expenseDetached);

    assertThat(expense).isEqualTo(expenseManaged);
}
persist vs merge

¿Qué sucede si el argumento de merge es una entidad nueva (transient)? Se sigue devolviendo un objeto managed que, además, se insertará en la tabla.

@Test
void testMergeNew() {
    Expense expenseNew = new Expense();
    expenseNew.setAmount(new BigDecimal("9.99"));
    expenseNew.setConcept("Lifecycle test");
    expenseNew.setDate(LocalDate.now());

    Expense expenseManaged = em.merge(expenseNew);

    assertThat(expenseManaged).isNotEqualTo(expenseNew);
}

Es lo que parece: merge se comporta igual que persist. Así pues, la existencia de persist supuestamente no tiene mucho sentido y, por ejemplo, la prueba testPersist sigue siendo correcta con este cambio.

 //em.persist(expense);         
expense = em.merge(expense);

Sin embargo, hay un caso donde el resultado de merge y persist se alcanza de manera distinta: las entidades en las que el identificador no es autogenerado, debido a la SELECT que indiqué líneas atrás que puede lanzar merge.

Sirva de ejemplo esta entidad. Su identificador se establece de forma manual.

@Entity
@Table(name = "languages")
public class Language {
    @Id
    private Long id;

    @Column
    private String code;

}

Las instancias de Language deben tener siempre su identificador antes de llamar a persist y merge. Si no es así, Hibernate lanza una excepción.

jakarta.persistence.PersistenceException: org.hibernate.id.IdentifierGenerationException: ids for this class must be manually assigned before calling save(): com.danielme.jakartaee.jpa.entities.Language

Guardemos un idioma con merge.

@Test
void testMergeNewWithId() {
    Language language = new Language();
    language.setId(System.currentTimeMillis());

    //check the log
    em.merge(language);
    em.flush();
}

Funciona, aunque en esta ocasión, a diferencia de lo que sucedía con Expense (identificador autogenerado), merge no puede deducir si el estado de language es transient o detached, pues en ambos casos siempre tendrá identificador. La consecuencia es que realizará dos operaciones: una SELECT que averiguar si el idioma existe en la tabla -no devolverá nada- y el INSERT que la guarda.

select language0_.id as id1_1_0_, language0_.code as code2_1_0_ from languages language0_ where language0_.id=?
insert into languages (code, id) values (?, ?)

Nosotros sí sabemos que la entidad es nueva, por lo que si la incluimos en el contexto con persist nos ahorramos la SELECT del merge. En este escenario tan concreto, persist es más eficiente que merge. Y, en todos los casos, aporta semántica al código y lo hace más riguroso.

Obtención de entidades

Llegados a este punto del curso, ya estamos acostumbrados a obtener entidades con el método find, indicando su clase e identificador. Si el registro no existe, se devuelve null.

Otra opción menos conocida es el método getReference. En apariencia, hace lo mismo que find: recuperar una entidad por su identificador. Pero hay una diferencia sutil, pues si la entidad aún no está en el contexto, el gestor de entidades devuelve un objeto representante (proxy) que solo tiene establecido el identificador. Ni siquiera obtiene el registro de base de datos, acción que se realizará cuando se intente acceder por primera vez a una propiedad de la entidad que no sea el identificador mediante un getter o un setter. En ese momento, si la entidad no existe se lanzará una excepción; lo demuestra la siguiente prueba.

@Test
void testGetReference() {
    Expense expenseReference = em.getReference(Expense.class, System.currentTimeMillis());

    assertThatExceptionOfType(EntityNotFoundException.class)
                .isThrownBy(() -> expenseReference::getConcept);
    }

Parece claro que getReference es preferible a find a la hora de trabajar con una entidad detached de la que no necesitemos acceder a ninguna de sus propiedades porque evitamos una SELECT. Un caso típico es el borrado.

@Test
@DataSet(value = "/datasets/expenses.yml")
void testRemoveReference() {
    Expense reference = em.getReference(Expense.class, Datasets.EXPENSE_ID);
    em.remove(reference);
    em.flush();
}

Tristemente, al ejecutar el código anterior me llevé un chasco: en la bitácora comprobé que Hibernate efectúa una sentencia SELECT para obtener el gasto justo antes de hacer el borrado.

18:23:15,498 DEBUG [org.hibernate.internal.SessionImpl] (default task-2) Initializing proxy: [com.danielme.jakartaee.jpa.entities.Expense#1]
18:23:15,498 DEBUG [org.hibernate.SQL] (default task-2) select expense0_.id as id1_0_0_, expense0_.amount as amount2_0_0_, expense0_.comments as comments3_0_0_, expense0_.concept as concept4_0_0_, expense0_.date as date5_0_0_ from expenses expense0_ where expense0_.id=?

Solo he encontrado un caso en el que el uso de getReference frente a find evita la consulta que obtiene la entidad: el establecimiento de una relación. Lo veremos en el próximo capítulo.

En Session encontramos varios métodos fuera del estándar para recuperar entidades. Los load equivalen a los find de JPA, pero lanzan una excepción si la entidad no se encuentra. Hay dos que obtienen entidades por sus claves naturales (@NaturalId), justo como explico aquí. Otro, denominado byMultipleIds, obtiene varias de una tacada con una sola consulta. Echémosle un rápido vistazo.

@Test
@DataSet(value = "/datasets/expenses.yml")
void testByMultipleIds() {
    Session session = em.unwrap(Session.class);
    List<Expense> expenses = session.byMultipleIds(Expense.class).multiLoad(Datasets.EXPENSE_ID,
                System.currentTimeMillis());
    assertThat(expenses).hasSize(2);
    assertThat(expenses.get(0)).isNotNull();
    assertThat(expenses.get(1)).isNull();
 }

Se realiza una consulta que aplica el operador IN a los identificadores proporcionados.

select
	expense0_.id as id1_0_0_,
	expense0_.amount as amount2_0_0_,
	expense0_.comments as comments3_0_0_,
	expense0_.concept as concept4_0_0_,
	expense0_.date as date5_0_0_
from
	expenses expense0_
where
	expense0_.id in (?,?)

Obsérvese que las entidades que no existan aparecerán en la lista como null. Es lo que comprueba la última línea de testByMultipleIds.

Código de ejemplo

El código de ejemplo del capítulo 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.

>>>> ÍNDICE <<<<

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.