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 WildFly 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. Veremos a fondo cómo funcionan las transacciones según lo definido en la especificación JTA. Hasta entonces, basta con saber que declaramos transaccional a un método de un CDI Bean con la anotación @Transactional.

(*) Existe otro modo de funcionamiento de uso complejo y muy poco habitual llamado extendido (Extended Persistence Context). No lo trataremos en el curso.

El contexto también se conoce como “caché de primer nivel” porque durante su existencia los objetos que representan a las entidades se reutilizan, evitándose llamadas innecesarias a la base de datos. Es decir, tenemos una suerte de caché de entidades. Cada contexto tiene su propia caché de primer nivel de uso exclusivo, en contraposición a la caché de segundo nivel, compartida por todos los contextos y que existe de forma permanente.

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. Los revisaremos en breves momentos.

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.

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 testPersistExpense() {

    }

}

Vamos a escribir el método testPersistExpense() 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 incluir en el contexto expense 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 de tipo identidad, Hibernate solo puede obtener su valor después de haber ejecutado la sentencia INSERT, tal y como ya expliqué.

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

Con cualquier otro tipo de clave primaria, esta operación no se lanzará hasta el momento del flush (*), método que fuerza la sincronización inmediata del contexto con la base de datos. Si, por ejemplo, empleamos una secuencia como generador, se obtendrá el siguiente valor disponible si Hibernate no tiene identificadores para asignar con seguridad. Este comportamiento también lo examinamos (configuración de allocationSize).

select nextval('hibernate_sequence')

(*) Al trabajar con @Transactional (JTA), será raro que llamemos a flush porque la sincronización se realiza automáticamente al finalizar la ejecución del método transaccional con un commit. Por cuestiones de eficiencia, Hibernate intenta retrasar lo máximo posible las operaciones de sincronización. En los test 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 ya comenté cuando hablamos de los identificadores de tipo identidad: estas entidades no se pueden insertar en lote (batch) porque se persisten de una en una.

Recuperamos otra vez el gasto con find y comprobamos que Hibernate devuelve el mismo objeto managed que ya existe en el contexto. Dicho de otro modo, se obtiene de la caché de primer nivel, y find ni siquiera tiene que realizar una consulta SELECT para obtenerlo.

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 volcó previamente con un INSERT en la tabla, 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, 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 gracias al aislamiento entre transacciones.

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.

Atención en el siguiente código que comprueba que expense y expenseDetached dentro del contexto son representadas 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 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. 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();
 }
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 existen aparecen 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 <<<<

Responder

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 )

Google photo

Estás comentando usando tu cuenta de Google. 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 .