Curso Jakarta EE 9 (24). JPA con Hibernate (7): Introducción y relaciones simples (many-to-one, one-to-one)

logo Jakarta EE

Pocas entidades, por no decir ninguna, van a ser clases “aisladas” sin referencias a otras del modelo de datos, o incluso a sí mismas. No haremos mucho si no sabemos declarar relaciones entre entidades, tarea que además tiene grandes implicaciones en el rendimiento de JPA por lo que debemos ser capaces de hacerlo de la mejor forma posible.

>>>> ÍNDICE <<<<

Relaciones

El concepto de relación ya lo vimos de pasada en el capítulo de introducción al mundo de las bases de datos relacionales en Java. Este era el ejemplo que usé.

ejemplo tablas relacionadas

La solicitud pertenece a un usuario solicitante que viene dado por la columna usuario_id, clave ajena en la tabla solicitudes que contiene la clave primaria del solicitante. Este tipo de relaciones son las más habituales con diferencia. Se dice que usuarios es la tabla padre de la relación, y solicitudes la tabla hija.

En un modelo orientado a objetos, este sería un posible diseño equivalente en formato diagrama de clases.

Usuario y Solicitud están vinculados por una relación. El sentido de la flecha indica su navegabilidad y los números definen la cardinalidad o multiplicidad: la cantidad de instancias de una clase que pueden asociarse con una instancia de la clase relacionada. Así pues, la relación se leería: una instancia de Usuario puede tener entre cero y n instancias de solicitudes asociadas. El sentido contrario no es navegable, pero el diagrama indica que una solicitud siempre está asociada con un solo usuario (la cardinalidad no se suele indicar cuando es 1 exacto, aunque a veces lo haré para una mayor claridad).

Lo anterior se modela con una lista de solicitudes en la clase Usuario.

public class Usuario {

	private Long id;
	private String nombre;
	private String apellidos;
	private LocalDate fechaNacimiento;
	private String email;
	private List<Solicitud> solicitudes;

El sentido único de la relación implica que no hay ninguna referencia entre usuario y solicitante, de manera que un objeto de Solicitud no conoce a su solicitante. Si cambiamos esto, la relación pasaría a ser de “unidireccional” a “bidireccional” porque se puede “navegar” en los dos sentidos. En este caso no se suele poner flecha alguna.

El concepto de bidireccional no existe en el modelo relacional, simplemente tenemos claves ajenas.

Ahora, Solicitud debe tener un atributo que contenga la referencia al usuario solicitante.

public class Solicitud {

	private Long id;
	private String titulo;
	private LocalDate fecha;
	private String descripcion;
	private Usuario usuario;

Si convertimos a las clases en entidades, debemos configurar los atributos usuario y solicitudes para que Hibernate sepa cómo tratar la relación. Los distintos tipos de relaciones se nombran en función de la cardinalidad. En el curso vamos a estudiarlas según el atributo que las define sea único o múltiple (una colección).

Con respecto al uso del gestor de entidades, debe quedar claro que si ejecutamos sus métodos sobre una entidad (persist, merge, remove…), la operación no se aplica o propaga a las entidades relacionadas, a menos que se configuren las operaciones en cascada de la relación. Lo trataremos en un capítulo específico.

Many-to-One

La relación más común, y que es precisamente la que acabamos de ver, es “de-muchos-a-uno”: muchas solicitudes, un usuario. Configura el atributo que refleja el “uno” de la asociación, el usuario de la clase Solicitud. Veámoslo con un ejemplo más cercano a lo que pretendemos construir con el curso.

En nuestra aplicación de gestión de presupuestos, los usuarios podrán organizar sus gastos por categorías, de tal modo que un gasto siempre va a estar asociado a una categoría. Estas serán creadas a conveniencia, así que no son un conjunto prefijado e inmutable susceptible de modelarse con un enumerado.

Esta es la entidad Category que usamos por primera vez en el curso.

@Entity
@Table(name = "categories")
@Getter
@Setter
public class Category {

    private static final int NAME_LENGTH = 50;
    private static final int COLOR_LENGTH = 6;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = NAME_LENGTH, unique = true)
    private String name;

    @Column(length = COLOR_LENGTH)
    private String colorHex;

}

El diagrama de clases muestra una relación unidireccional que va del gasto a la categoría. En la base de datos, esto se traduce en que la tabla hija con los gastos tiene una clave ajena a su tabla padre (categoría).

En JPA, la relación se configura marcando con @ManyToOne el atributo de Expense con la categoría, o bien su getter si usamos el modo property access.

@Entity
@Table(name = "expenses")
@Getter
@Setter
public class Expense {
    
    @ManyToOne
    private Category category;

Más fácil, imposible. Hibernate asume que en la tabla expenses hay una clave ajena que hace referencia a la clave primaria de la categoría. El nombre de la columna se compone con el nombre del atributo que modela la relación, un guion bajo y el nombre del identificador de Category: category_id. Podemos indicar un nombre de forma explícita con @JoinColumn, la anotación que configura la columna con la clave ajena, y que comparte algunas propiedades con @Column.

@ManyToOne
@JoinColumn(name = "cat_id")
private Category category;

Vamos a declarar la restricción que obliga a que un gasto tenga establecida su categoría, lo que supone que en la tabla la columna category_id no puede ser nula. En JPA, lo indicamos con el atributo optional de @ManyToOne.

@ManyToOne(optional = false)
private Category category;

Si la relación debe existir siempre, no es opcional, así que ponemos optional a false (el valor predeterminado es true). En el caso de que Hibernate cree las tablas, category_id tendrá la restricción NOT NULL.

“Juguemos” con las relaciones escribiendo algunos tests apoyados en datos o datasets predefinidos. Nada que no hayamos hecho ya en el proyecto jpa-entity. El proyecto de ejemplo para los capítulos sobre las relaciones se denomina jpa-relations. La única peculiaridad es que todas las clases de pruebas heredan de BaseRelationsTest.

package com.danielme.jakartaee.jpa;

import com.danielme.jakartaee.jpa.extensions.ArquillianDBUnitExtension;
import com.github.database.rider.core.api.connection.ConnectionHolder;
import jakarta.annotation.Resource;
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.extension.ExtendWith;

import javax.sql.DataSource;
import java.sql.Connection;

@ExtendWith(ArquillianExtension.class)
@ExtendWith(ArquillianDBUnitExtension.class)
@Transactional
public class BaseRelationsTest {

    @PersistenceContext
    protected EntityManager em;

    @Resource(lookup = "java:/jdbc/personalBudgetDS")
    private DataSource dataSource;

    private Connection connection;

    ConnectionHolder buildConnectionHolder() {
        return () -> {
            if (connection == null || connection.isClosed()) {
                connection = dataSource.getConnection();
            }
            return connection;
        };
    }

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

Empecemos obteniendo el gasto de la forma habitual con find. Accedemos a su categoría con el getter. La siguiente prueba se encuentra en la clase ManyToOneTest.

 @Test
 void testFind() {
    Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_1);

    assertThat(expense.getCategory().getId())
                 .isEqualTo(Datasets.CATEGORY_ID_FOOD);
 }

En la bitácora del servidor, vemos que find obtiene los datos realizando una unión o join entre las tablas de gastos y categorías. Esto es debido a que las relaciones Many-to-One se obtienen de forma predeterminada en modo “inmediato” (EAGER). Lo estudiaremos en un capítulo específico.

 select
	expense0_.id as id1_1_0_,
	expense0_.amount as amount2_1_0_,
	expense0_.category_id as category6_1_0_,
	expense0_.comments as comments3_1_0_,
	expense0_.concept as concept4_1_0_,
	expense0_.date as date5_1_0_,
	category1_.id as id1_0_1_,
	category1_.colorHex as colorHex2_0_1_,
	category1_.name as name3_0_1_
from
	expenses expense0_
inner join categories category1_ on
	expense0_.category_id = category1_.id
where
	expense0_.id =?

La relación no es opcional, así que al crear un nuevo gasto debemos asignarle una categoría (la columna expenses.category_id nunca tendrá un valor nulo). De lo contrario, se lanzará una PersistenceException de JPA, consecuencia a su vez de la excepción ConstraintViolationException (“restricción incumplida”) de Hibernate. Esta es la pila de excepciones.

jakarta.persistence.PersistenceException: org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: java.sql.SQLIntegrityConstraintViolationException: Column 'category_id' cannot be null

La siguiente prueba provoca el error y comprueba la excepción.

 @Test
 void testPersistNoCategory() {
       Expense expense = createExpense();

        assertThatExceptionOfType(PersistenceException.class)
                .isThrownBy(() -> em.persist(expense));
    }

Hagamos una inserción correcta de un nuevo gasto. Hibernate guardará el identificador de la categoría en la clave ajena de forma transparente.

    @Test
    void testPersistGetReference() {
        Expense expense = createExpense();
        expense.setCategory(em.getReference(Category.class, Datasets.CATEGORY_ID_FUEL));

        em.persist(expense);
    }

Para establecer en expense su categoría, la he obtenido con el método getReference el cual retorna un objeto proxy o representante de la entidad que solo contiene el identificador que hemos indicado, tal y como expliqué en el capítulo anterior. El objetivo es ahorramos la SELECT que ejecutaría el método find porque Hibernate solo requiere el identificador de la categoría para establecer la clave ajena, y ese dato ya está en el proxy. Pero esto es la teoría, pues en la práctica la entidad se obtiene justo antes de hacer la inserción.

Initializing proxy: [com.danielme.jakartaee.jpa.entities.Category#2] 
select category0_.id as id1_3_0_, category0_.color_hex as color_he2_3_0_, category0_.name as name3_3_0_ from categories category0_ where category0_.id=?
insert into expenses (amount, category_id, comments, concept, date) values (?, ?, ?, ?, ?)

Supongo que este comportamiento es consecuencia de la necesidad de comprobar la existencia del gasto cuando se accede a cualquier propiedad, incluyendo el identificador, para lanzar EntityNotFoundException si fuera necesario. Este comportamiento es requerido por JPA, pero unas pocas restricciones del estándar pueden desactivarse en Hibernate con varias propiedades de configuración. Lo que necesitamos es establecer hibernate.jpa.compliance.proxy a falso en el fichero persistence.xml , a pesar de que, sorprendentemente, la documentación señala que es su valor por omisión.

<property name="hibernate.jpa.compliance.proxy" value="false"/>

Podemos conseguir el mismo resultado con este “truco”.

    @Test
    void testPersistCreateCategory() {
        Expense expense = createExpense();
        Category category = new Category();
        category.setId(Datasets.CATEGORY_ID_FOOD);
        expense.setCategory(category);

        em.persist(expense);
    }

No importa que la categoría asociada al gasto no se corresponda con una entidad en estado managed, tal y como parecería lógico. En cualquier caso, es más seguro usar getReference porque si la relación tiene las operaciones en cascada -lo veremos en un capítulo específico- all o persist, con la técnica anterior se produciría el siguiente error.

jakarta.persistence.PersistenceException: org.hibernate.PersistentObjectException: 
    detached entity passed to persist: com.danielme.jakartaee.jpa.entities.Category

El cambio de la categoría del gasto no tiene mayor complicación. En la siguiente prueba, al gasto 1, de categoría 1, le ponemos la categoría 2. Para comprobar la actualización de la entidad, se ejecuta un flush (vuelca los cambios pendientes en la base de datos) y se desliga el gasto del contexto con el objetivo de forzar su nueva obtención desde la tabla.

    @Test
    void testUpdateCategory() {
        Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_1);        
        expense.setCategory(expense.setCategory(em.getReference(Category.class, Datasets.CATEGORY_ID_FUEL)););

        em.flush();
        em.detach(expense);

        Category currentCategory = em.find(Expense.class, Datasets.EXPENSE_ID_1).getCategory();
        assertThat(currentCategory.getId())
                .isEqualTo(Datasets.CATEGORY_ID_FUEL);
    }

La secuencia de operaciones realizadas en la base de datos no deja lugar a dudas. Aparece una SELECT para cada find y, lo más importante, el UPDATE.

select expense0_.id as id1_1_0_, ...

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

 select expense0_.id as id1_1_0_, ...

¿Y si quisiéramos que la relación fuera bidireccional? Tenemos que añadir una colección anotada con @ManyToOne a Category. Lo veremos en el próximo capítulo.

One-to-One

La asociación uno-a-uno establece una relación en la que los dos extremos están vinculados con cardinalidad 1. En personalBudget tenemos lo siguiente.

Un cupón de descuento nos permite aplicar una rebaja sobre el desembolso total que supone un gasto. El cupón podrá usarse o no, pero cuando se utilice solo se hará sobre un gasto. Por este motivo, un cupón se asocia con un gasto o ninguno. Del mismo modo, un gasto puede haberse pagado en su totalidad, o bien tener un precio rebajado porque se utilizó un cupón de igual o menor cuantía. En consecuencia, los gastos están asociados con un cupón, o ninguno.

De entrada, vamos a definir la relación como unidireccional, viéndola desde el cupón al gasto. Esto es, el cupón tiene una referencia a su gasto, y en su tabla tendremos la correspondiente clave ajena.

Esta sería la entidad.

@Entity
@Table(name = "coupons")
@Getter
@Setter
public class Coupon {

    private static final int NAME_LENGTH = 50;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length =  NAME_LENGTH, nullable = false)
    private String name;

    private LocalDate expiration;

    @Column(nullable = false, precision = 14, scale = 2)
    private BigDecimal amount;

    @OneToOne
    private Expense expense;

}

La relación se ha definido igual que si fuera de tipo many-to-one con la anotación a @OneToOne. Se aplican las mismas observaciones.

  • Se asume que el nombre de la columna con la clave ajena es el nombre del atributo más guion bajo más nombre del atributo identificador: expense_id.
  • La relación es opcional, por lo que la clave ajena puede ser nula. Esto se debe a que el valor predeterminado del parámetro optional de la anotación es true. Esta vez es lo que queremos.

Obsérvese que un gasto solo puede asociarse con un cupón; una clave primaria de expenses aparece como máximo una vez en coupons. Dicho de otro modo, la relación es exclusiva. Esta restricción se impone en el modelo relacional haciendo que la columna de la clave ajena sea única. En JPA, lo declaramos así.

 @OneToOne
 @JoinColumn(unique = true)
 private Expense expense;

El uso de la relación tampoco difiere de lo que vimos para many-to-one. La siguiente prueba, perteneciente a la clase OneToOneTest, actualiza el gasto de un cupón ya existente en la tabla y que lo tenía a null.

    @Test
    void testUpdateCouponUnidirectional() {
        Coupon coupon = em.find(Coupon.class, Datasets.COUPON_ID_ACME);
        coupon.setExpense(em.getReference(Expense.class, Datasets.EXPENSE_ID_2));

        em.flush();
        em.detach(coupon);

        assertThat(em.find(Coupon.class, Datasets.COUPON_ID_ACME).getExpense())
                .isNotNull();
    }

En el log veremos la sentencia UPDATE.

 update coupons set amount=?, expense_id=?, expiration=?, name=? where id=?

De forma predeterminada, la obtención de un cupón no incluye los datos del posible gasto asociado, y este se recupera de su tabla cuando accedamos a una propiedad que no sea su identificador. Las relaciones one-to-one unidireccionales se obtienen en modo perezoso (LAZY) bajo demanda. Por ello, para las operaciones

Coupon coupon = em.find(Coupon.class, Datasets.COUPON_ID_ACME);
coupon.getExpense().getConcept();

se generan dos consultas

select expense0_.id as id1_6_0_, expense0_.amount as amount2_6_0_, ...

select coupon0_.id as id1_4_0_, coupon0_.amount as amount2_4_0_, coupon0_.expense_id as expense_5_4_0_, coupon0_.expiration as expirati3_4_0_, coupon0_.name as name4_4_0_ from coupons coupon0_ where coupon0_.expense_id=?

Más información en un próximo capítulo específico.

Bidireccional

La relación entre Coupon y Expense pasa a ser bidireccional si en Expense se define otra relación one-to-one hacia Coupon.

Parece razonable hacer lo siguiente.

@Entity
@Table(name = "expenses")
public class Expense {
    @OneToOne
    private Coupon coupon;

No es lo que queremos porque de este modo se ha configurado una nueva relación unidireccional. Lo que tenemos que hacer es usar la propiedad mappedBy para indicar el nombre del atributo de la entidad del otro extremo que define la relación y que es quien determina cuál es la clave ajena. Esa entidad se denomina propietaria (owner) de la relación bidireccional.

@OneToOne(mappedBy = "expense")
private Coupon coupon;

¿Qué entidad se elige como propietaria si la relación es bidireccional? Debemos fijarnos en la forma en la que vamos a utilizarla: la clave ajena es mejor que esté en la entidad desde la que sea más probable que queramos usar la relación.

Repitamos el método testUpdateCouponUnidirectional visto desde el otro extremo.

    @Test
    void testUpdateExpenseBidirectional() {
        Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_2);
        expense.setCoupon(em.find(Coupon.class, Datasets.COUPON_ID_ACME));
        expense.getCoupon().setExpense(expense);

        em.flush();
        em.detach(expense);

        assertThat(em.find(Expense.class, Datasets.EXPENSE_ID_2).getCoupon())
                .isNotNull();
    }

Si tenemos relaciones bidireccionales hay que mantener la coherencia de la asociación en ambos lados (líneas 4 y 5). No obstante, tengamos presente que la clave ajena solo se actualiza en la tabla si cambia el objeto Expense que hay en Coupon, la entidad propietaria. Si comentamos la línea 5, la aserción fallará porque no se ejecuta el UPDATE sobre la tabla de cupones.

Por último, merece la pena indicar que en los one-to-one bidireccionales la relación solo puede obtenerse de forma LAZY desde la entidad propietaria.

@MapsId

Examinemos otro ejemplo de relación one-to-one.

Estamos ante una asociación one-to-one unidireccional y no opcional, con la entidad Preferences como propietaria de la relación. Una preferencia pertenece sin remedio a un usuario que siempre será el mismo. Podemos modelar la entidad así.

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "preferences")
@Getter
@Setter
public class Preferences {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private boolean enableNotifications;
	private boolean enableSharing;

	@OneToOne(optional = false)
	@JoinColumn(unique = true, updatable = false)
	private User user;
	
}

Lo más interesante de este escenario no es evidente. Si una preferencia siempre pertenece al mismo usuario, ambas entidades pueden compartir el identificador. Esto implica que ya no se precisa una columna específica en preferences que almacene la clave ajena, porque la clave primaria y la ajena pueden declararse sobre la misma columna, y además su valor coincide con la clave primaria del usuario.

JPA contempla esta casuística con la anotación @MapsId.

@Entity
@Table(name = "preferences")
@Getter
@Setter
public class Preferences {

	@Id
	private Long id;

	private boolean enableNotifications;
	private boolean enableSharing;

	@OneToOne
	@MapsId
	private User user;

}

Con esta configuración, en la tabla preferences tendremos la columna user_id que es a la vez clave ajena y primaria, y ya no hay columna id. Es obligatorio seguir declarando el atributo identificador, que ahora no es autogenerado.

Hibernate crea la tabla así.

create table preferences (
	enableNotifications bit not null,
	enableSharing bit not null,
	user_id bigint not null,
	primary key (user_id)
	) engine = InnoDB

alter table preferences 
	add constraint FKme1hmam0h8w07410h7qkjna5m 
	foreign key (user_id) references users (id);

Desde el punto de la base de datos, esta configuración tan astuta es muy eficiente, pues nos ahorramos una columna que además debe estar indexada como única. Ganamos espacio y velocidad. Para el programador, también aporta beneficios: conoce cuál es el registro de preferences correspondiente a un usuario sin tener que buscarlo y puede recuperarlo directamente con find. No hace falta que la relación sea bidireccional, lo cual, veremos más adelante en el curso, en el caso de one-to-one puede ser problemático.

Este es un ejemplo de uso de la relación. Se crean las preferencias de un nuevo usuario sin definir su identificador (¡no hace falta!), y luego se recupera a partir del identificador del usuario.

    @Test
    void testCreatePreference() {
        User user = new User();
        user.setName("test");
        em.persist(user);
        Preferences preferences = new Preferences();
        preferences.setUser(user);
        
        em.persist(preferences);
        em.flush();
        em.clear();

        assertThat(em.find(Preferences.class, user.getId())).isNotNull();
    }

@MapsId también se puede usar en relaciones one-to-one bidireccionales.

El código de ejemplo de los capítulos dedicados a las relaciones 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 .