Curso Jakarta EE 9 (25). JPA con Hibernate (8): Relaciones múltiples (one-to-many, many-to many)

logo Jakarta EE

Las relaciones básicas que vimos en el capítulo anterior se completan con aquellas en las que la entidad tiene una colección de instancias de la entidad asociada.

>>>> ÍNDICE <<<<

One-to-many

Bidireccional

Esta relación se suele usar para representar el otro extremo de las many-to-one y convertirlas en bidireccionales.

En el capítulo anterior creamos esta relación unidireccional.

Expense cuenta con una referencia a su categoría que a su vez se corresponde con una clave ajena en la tabla expenses.

@Entity
@Table(name = "expenses")
@Getter
@Setter
public class Expense {

    @ManyToOne(optional = false)
    private Category category;

La relación será bidireccional añadiendo una colección -lo habitual es una List– a la categoría que contenga todos sus gastos.

@Entity
@Table(name = "categories")
public class Category {
    @OneToMany(mappedBy = "category")
    private List<Expense> expenses;

Es lo mismo que hicimos para definir el extremo sin clave ajena -la entidad que no es propietaria de la relación- en las relaciones one-to-one bidireccionales. mappedBy indica el atributo de Expense que representa el otro lado de la relación. Sin este atributo, @OneToMany y @OneToOne serán unidireccionales.

Es posible configurar la ordenación de la lista con @OrderBy, indicando el nombre del atributo y el criterio de ordenación, ascendente (ASC, valor predeterminado) o descendente (DESC). Se admiten múltiples campos separados por comas. Si no se indica ninguno, se ordena por el identificador.

 @OneToMany(mappedBy = "category")
 @OrderBy("date DESC, amount DESC")
 private List<Expense> expenses;

Hagamos una prueba rápida.

   @Test
    void testBidirectional() {
        Category category = em.find(Category.class, Datasets.CATEGORY_ID_FOOD);
        assertThat(category.getExpenses()).hasSize(2);
    }

testBidirectional, codificado en la clase @OneToMany, obtiene los gastos de una categoría. En la bitácora veremos dos consultas: la primera recupera la categoría y la segunda sus gastos. Esto es porque por omisión la relación one-to-many es “perezosa” (lazy) y las entidades no se obtienen hasta que se soliciten por primera vez llamando al getter. Lo estudiaremos en un capítulo específico.

Además de resultar fácil de configurar, parece razonable y cómodo que las many-to-one sean bidireccionales combinándolas con one-to-many. Nada más alejado de la realidad: recomiendo evitar, como regla general, este tipo de relación. Hagámonos siempre la siguiente pregunta: ¿cuántos elementos puede contener la colección? En una categoría, es factible tener cientos o miles de gastos. Esto significa que cuando invoquemos a getExpenses ¡traeremos de la base de datos miles de registros de una tacada! El rendimiento de la aplicación se hundirá. Nótese que @OneToMany no permite limitar el tamaño de la colección.

Cuando la aplicación empiece a utilizarse, dado que todavía apenas hay datos, las obtenciones de las listas de las relaciones del tipo getExpenses no suponen un problema. Pero a medida que la base de datos crece, la aplicación se va ralentizando poco a poco sin motivo aparente, hasta que llega un momento en que el rendimiento es penoso, o incluso el sistema colapsa por falta de memoria. A veces el problema es fácil de detectar (por ejemplo, una pantalla que tarda mucho en cargarse), en otras ocasiones hay que buscarlo.

Lo anterior es un fallo de principiantes que he visto con demasiada frecuencia. La manera eficiente de obtener la colección de entidades que representa @OneToMany es hacerlo de manera “paginada” en bloques de “n” elementos, por ejemplo de diez en diez, con una consulta. La relación entre las entidades ya queda bien definida con @ManyToOne porque establece la clave ajena que necesitamos; usemos la relación bidireccional solo en el caso de que sepamos de antemano que la lista tendrá un número pequeño de elementos y queramos tener esa lista en la entidad.

Unidireccional

One-to-many también se puede utilizar de forma unidireccional, esto es, sin vincularla con una relación many-to-one. Tomemos este ejemplo.

@Entity
@Table(name = "users")
public class User {

    @OneToMany
    private List<Coupon> coupons;

¿Dónde está clave ajena? No, desde luego, en la tabla users porque un usuario tiene más de un cupón. JPA tampoco espera que exista en coupons dado que ese lado de la asociación no está configurado como una relación. La solución que adopta Hibernate para vincular usuarios y cupones es la siguiente.

Tenemos una tabla “intermedia” que asocia usuarios y cupones con una clave ajena para cada extremo de la relación. Este diseño es ineficiente, veamos sus defectos de forma empírica con esta prueba.

    @Test
    @DataSet(executeStatementsBefore = {"TRUNCATE users_coupons"})
    void testUnidirectional() {
        Coupon coupon = new Coupon();
        coupon.setAmount(new BigDecimal("24"));
        coupon.setName("test");
        User user = new User();
        user.setName("test coupon");
        user.setCoupons(List.of(coupon));

        em.persist(user);
        em.flush();
        em.detach(user);

        User userInserted = em.find(User.class, user.getId());
        assertThat(userInserted.getCoupons()).hasSize(1);

        em.remove(userInserted);
        em.flush();
    }

Estamos insertando y borrando un nuevo usuario junto con un nuevo cupón. Esto es posible porque la relación se ha configurado así.

@OneToMany(cascade = CascadeType.ALL)
 private List<Coupon> coupons;

La creación de las dos entidades implica tres INSERT, porque hay que crear también un registro en la tabla users_coupons.

insert into users (name) values (?)
insert into coupons (amount, expense_id, expiration, name) values (?, ?, ?, ?)
insert into users_coupons (User_id, coupons_id) values (?, ?)

Lo mismo aplica para el borrado.

delete from users_coupons where User_id=?
delete from coupons where id=?
delete from users where id=?

Por si esto no fuera poco, la obtención de los datos requiere de un join extra.

 select
	coupons0_.User_id as User_id1_5_0_,
	coupons0_.coupons_id as coupons_2_5_0_,
	coupon1_.id as id1_1_1_,
	coupon1_.amount as amount2_1_1_,
	coupon1_.expiration as expirati3_1_1_,
	coupon1_.name as name4_1_1_
from
	users_coupons coupons0_
inner join coupons coupon1_ on
	coupons0_.coupons_id = coupon1_.id
where
	coupons0_.User_id =?

Además de las operaciones adicionales que requiere la existencia de la tabla users_coupons, tenemos un diseño que haría, probablemente, fruncir el ceño a un DBA.

Evitamos la tabla intermedia indicando a Hibernate que use una clave ajena en la tabla del otro lado de la relación.

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn
private List<Coupon> coupons;

Si bien este cambio supone una gran mejora, seguimos teniendo inconvenientes. Al lanzar la prueba con esta nueva configuración, veremos que la inserción de los dos registros sigue requiriendo de una operación adicional: un UPDATE que establece la clave ajena.

insert into users (name) values (?)
insert into coupons (amount, expense_id, expiration, name) values (?, ?, ?, ?)
update coupons set coupons_id=? where id=?

Olvidemos las relaciones unidireccionales de tipo one-to-many, pues contamos con mejores alternativas que la hacen innecesaria.

Many-to-many

Examinemos el caso en el que dos entidades se relacionen de forma múltiple al mismo tiempo con sendas colecciones.

Un presupuesto (budget) determina la cantidad máxima de dinero que puede gastarse durante cierto periodo de tiempo, o bien de forma indefinida hasta que la cantidad se agote. Solo abarca a los gastos que pertenezcan a ciertas categorías, al menos una. Se pueden crear presupuestos ilimitados para las categorías que se quieran. De este modo, un presupuesto está asociado a muchas categorías, y una categoría puede usarse en muchos presupuestos: es una relación muchos-a-muchos.

Ahora no está claro dónde ubicar la clave ajena. Si estuviera en la tabla budgets, un presupuesto solo se aplicaría a los gastos pertenecientes a una única categoría. Si la ponemos en categories, solo se podría crear un presupuesto por categoría. En esta ocasión, no queda más remedio que mantener la relación en una tabla intermedia (join table) en la que cada registro vincule un presupuesto con una categoría.

En la base de datos tendríamos una estructura tal que así.

Unidireccional

Vamos a crear la relación many-to-many unidireccional del diagrama de clases con una lista de categorías en la entidad Budget.

@Entity
@Table(name = "budgets")
@Getter
@Setter
public class Budget {

    private static final int NAME_LENGTH = 50;

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

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

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

    @Column(nullable = false)
    private LocalDate dateFrom;

    private LocalDate dateTo;

    @ManyToMany
    private List<Category> categories;

}

La correspondencia con la base de datos es la mostrada en la última ilustración. Con @JoinTable podemos configurar el nombre de la tabla intermedia y de las claves ajenas. También es posible definir la ordenación si se desea.

    @ManyToMany
    @JoinTable(name = "budgets_categories",
            joinColumns = @JoinColumn(name = "budget_id"),
            inverseJoinColumns = @JoinColumn(name = "category_id")
    )
    private List<Category> categories;

Si la colección es de tipo Set, Hibernate crea una clave primaria para budgets_categories compuesta de todas las ajenas. En este tipo de tablas, y contraviniendo mi recomendación general, lo más práctico es usar una clave primaria \identificador múltiple.

Probemos la obtención del presupuesto usando el siguiente juego de datos y un test en la clase ManyToManyTest.

budgets:
  - id: 1
    name: August
    amount: 300
    date_from: 2021-08-01
    date_to: 2021-08-31
budgets_categories:
  - budget_id: 1
    category_id: 1
  - budget_id: 1
    category_id: 2
@DataSet(value = {"/datasets/budgets.yml"})
public class ManyToManyTest extends BaseRelationsTest {

    @Test
    void testFind() {
        Budget budget = em.find(Budget.class, Datasets.BUDGET_ID_AUGUST);

        assertThat(budget.getCategories()).hasSize(2);
    }

Hibernate recupera las categorías de forma perezosa aplicando un join con la tabla budgets_categories.

 select
	categories0_.budget_id as budget_i1_1_0_,
	categories0_.category_id as category2_1_0_,
	category1_.id as id1_2_1_,
	category1_.colorHex as colorHex2_2_1_,
	category1_.name as name3_2_1_
from
	budgets_categories categories0_
inner join categories category1_ on
	categories0_.category_id = category1_.id
where
	categories0_.budget_id =?
order by
	category1_.name desc
Bidireccional

De nuevo, podemos hacer la asociación bidireccional gracias a la propiedad mappedBy.

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

    @ManyToMany(mappedBy = "categories")
    private List<Budget> budgets;

¿Cuál es la entidad propietaria de la relación si ninguna posee la clave ajena? Aquella que no tiene el mappedBy, la única en la que se puede configurar la tabla intermedia. Recordemos que desde la entidad propietaria se propagan los cambios de la relación a la base de datos.

Con many-to-many experimentamos la misma problemática en lo que respecta al rendimiento que con one-to-many si las colecciones son grandes. Cabe esperar que un presupuesto comprenda unas pocas categorías, pero en el otro extremo esta suposición no parece demasiado realista. Además, si bien tiene sentido que los presupuestos incluyan las categorías, pues es un rasgo fundamental de los mismos, será raro que nos interesen conocer todos los presupuestos en los que se usa cada categoría, dato que siempre podremos recuperar con una eficiente consulta paginada. Así pues, nuestro ejemplo solo debería ser navegable desde Budget.

Aclarado lo anterior, vamos a usar la relación bidireccional para familiarizarnos con ella, aunque no tiene ningún “intríngulis” reseñable. Crearemos un nuevo presupuesto para dos categorías, manteniendo sincronizados los dos extremos de la relación como es habitual.

   @Test
    void testPersist() {
        Budget budget = new Budget();
        budget.setAmount(new BigDecimal("200"));
        budget.setDateFrom(LocalDate.now());
        budget.setName("test");
        Category categoryFuel = em.find(Category.class, Datasets.CATEGORY_ID_FUEL);
        Category categoryFood = em.find(Category.class, Datasets.CATEGORY_ID_FOOD);
        budget.setCategories(List.of(categoryFuel, categoryFood));
        categoryFuel.getBudgets().add(budget);
        categoryFood.getBudgets().add(budget);

        em.persist(budget);
        em.flush();
        em.detach(budget);

        assertThat(em.find(Budget.class, budget.getId()).getCategories()).hasSize(2);
    }

Hibernate insertará el presupuesto y los dos registros que lo asocian con las dos categorías.

insert into budgets (amount, date_from, date_to, name) values (?, ?, ?, ?)
insert into budgets_categories (budget_id, category_id) values (?, ?)
insert into budgets_categories (budget_id, category_id) values (?, ?)

Tampoco supondrá problema alguno el borrado de un presupuesto. Hibernate lo elimina junto a sus relaciones aunque conservando las categorías. Solo necesita un DELETE para actualizar budgets_categories.

    @Test
    void testDelete() {
        Budget budget = em.find(Budget.class, Datasets.BUDGET_ID_AUGUST);

        em.remove(budget);
        em.flush();

        assertThat(em.find(Category.class, Datasets.CATEGORY_ID_FOOD)).isNotNull();
    }
delete from budgets_categories where budget_id=?
delete from budgets where id=?

La eliminación de la relación, no de las entidades, presenta un problema de rendimiento que analizaremos al final de este capítulo.

Tipos de colecciones

Hasta el momento, todas las relaciones múltiples se han definido con List, y esto nos sirve en la inmensa mayoría de situaciones. No obstante, hay otras opciones, como por ejemplo Map -lo veremos en un próximo capítulo- y Set. Conviene señalar que Hibernate usa sus propias implementaciones de estas interfaces y no las proporcionadas por Java.

La gran diferencia entre List y Set es la unicidad de sus elementos. Los Set no admiten elementos duplicados y las listas sí. La contrapartida es que su contrato no define un orden de su contenido y este queda en manos de la implementación. Sin embargo, esto no supone un gran escollo porque si hacemos lo siguiente

    @OneToMany(mappedBy = "category")
    @OrderBy("date DESC, amount DESC")
    private Set<Expense> expenses;

@OrderBy instruye a Hibernate para que use una implementación de Set que mantenga el orden, tal y como hace LinkedHashSet.

El verdadero inconveniente al usar Set se encuentra en la implementación de los métodos equals y hashCode de las entidades para determinar la igualdad de sus objetos. Tengamos presente que la implementación que Java otorga a todos los objetos comprueba que sean exactamente el mismo (operador ==). Ya vimos que dentro de un contexto de persistencia una entidad siempre está representada por el mismo objeto, almacenado en la caché de primer nivel de Hibernate. Por consiguiente, en este escenario nos sirve la igualdad predeterminada. La situación cambia cuando estamos fuera del contexto y trabajamos con entidades transient y desligadas.

Echemos un vistazo a este código del proyecto jpa-entity.

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

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

La aserción es correcta porque expense y expenseDetached son objetos distintos, pese a que ambos representan a la misma entidad. La llamada a find en la línea 9 creó un nuevo objeto managed del gasto número 1 porque esa entidad ya no está en el contexto tras el detach realizado a expenseDetached. Si queremos que objetos distintos que representen a la misma entidad sean considerados iguales, tendremos que implementar el equals y hashCode. Esto nos garantiza, entre otros, la integridad de los Set.

La primera idea consiste en recurrir al identificador de la entidad, ya que su cometido es identificarlas de forma unívoca. Lastimosamente, esto no nos servirá si es generada por la base de datos porque en las entidades transient será nula hasta que formen parte por primera vez de un contexto de persistencia. Ante esta situación, buscaremos atributos cuyos valores sean únicos, obligatorios e inmutables a lo largo de todo el ciclo de vida de la entidad. O, lo que es lo mismo, una clave natural.

@Entity
@Table(name = "users_naturalid")
@NaturalIdCache
@Getter
@Setter
public class UserNaturalId {

    @Id
    private Long id;

    //@Column(unique = true, nullable = false, updatable = false)
    @NaturalId
    @Column(nullable = false)
    private Integer number;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserNaturalId that = (UserNaturalId) o;
        return number.equals(that.number);
    }

    @Override
    public int hashCode() {
        return Objects.hash(number);
    }
}

Este ejemplo, disponible en el proyecto jpa-entity, es evidente, pero muchas entidades no nos lo podrán nada fácil.

El hecho de que las listas no admitan duplicados no suele suponer ningún problema y su API es muy cómoda, de ahí que la mayoría de programadores las usen en las relaciones. Pero la bibliografía sobre Hibernate suele explicar un caso en el que el uso de Set frente a List es más eficiente.

Set en many-to-many

Ejecutemos el siguiente test con las trazas de Hibernate activadas en WildFly.

    @Test
    void testDeleteBudgetCategory() {
        Budget budget = em.find(Budget.class, Datasets.BUDGET_ID_AUGUST);
        Category category = em.find(Category.class, Datasets.CATEGORY_ID_FOOD);
        budget.getCategories().remove(category);
        category.getBudgets().remove(budget);

        em.flush();
    }

Se elimina la relación del presupuesto con una de las dos categorías que tiene asociada, en concreto la de identificador 1. Operación que no es lo mismo que borrar un presupuesto (testDelete). El SQL ejecutado es curioso (e inquietante).

delete from budgets_categories where budget_id=?
  binding parameter [1] as [BIGINT] - [1]
insert into budgets_categories (budget_id, category_id) values (?, ?)
  binding parameter [1] as [BIGINT] - [1]
  binding parameter [2] as [BIGINT] - [2]

Este comportamiento lo encontramos en las relaciones many-to-many tanto unidireccionales como bidireccionales. Hibernate elimina todas las categorías del presupuesto y vuelve a insertar las que continúan estando relacionadas (*). Se realizan operaciones redundantes que pueden ser muy numerosas. Es posible realizar un borrado óptimo y a medida escribiendo el DELETE con JPQL, pero a nivel de configuración tenemos la opción de recurrir a Set.

(*) Me adelanto y señalo que el mismo problema lo encontramos en las relaciones one-to-many unidireccionales con la opción orphanRemoval y al usar @ElementCollection.

    @ManyToMany
    @JoinTable(name = "budgets_categories",
            joinColumns = @JoinColumn(name = "budget_id"),
            inverseJoinColumns = @JoinColumn(name = "category_id")
    )
    @OrderBy("name DESC")
    private Set<Category> categories;

Ahora solo se elimina el registro de la tabla intermedia que ya no es necesario.

delete from budgets_categories where budget_id=? and category_id=?
   binding parameter [1] as [BIGINT] - [1]
   binding parameter [2] as [BIGINT] - [1]

Código de ejemplo

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 .