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.

Problemas de rendimiento

Hagamos una prueba rápida de la relación.

@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. Sin embargo, hagámonos 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 con detenimiento.

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 forma «paginada» en bloques de «n» elementos, por ejemplo de diez en diez, escribiendo una consulta. Aunque lo estudiaremos con detalle, me adelanto y muestro cómo recuperar los diez primeros gastos de una categoría de acuerdo a su fecha.

@Test
void testFetchCategoryExpenses() {
    List<Expense> expenses = em.createQuery("SELECT e From Expense e WHERE e.category.id = :id " +
            "ORDER BY e.date DESC", Expense.class)
                               .setParameter("id", Datasets.CATEGORY_ID_FOOD)
                               .setFirstResult(0)
                               .setMaxResults(10)
                               .getResultList();

    assertThat(expenses)
            .extracting(Expense::getId)
            .containsExactly(Datasets.EXPENSE_ID_2, Datasets.EXPENSE_ID_1);
}

Dado que @ManyToOne establece la clave ajena que necesitamos para declarar la relación, una buena recomendación es hacerla bidireccional solo en el caso de que sepamos de antemano que la lista tendrá un número muy pequeño de elementos y queramos tener esa lista en la entidad. Así, evitamos la obtención por error o desconocimiento de colecciones gigantescas. En general, se deberían crear las relaciones mínimas imprescindibles.

No obstante, la ausencia de una relación tiene un precio: los mecanimos de consultas de JPA (JPQL y Criteria API) solo pueden reunir (el equivalente a los join de SQL) directamente entidades relacionadas (el lenguaje HQL de Hibernate no tiene esta limitación). Si nos falta un extremo de una relación, la escritura de ciertas consultas puede ser complicada o inviable. Por este motivo, en los proyectos de ejemplos de los restantes capítulos del curso he dejado la relación Category-Expense como bidireccional, pero sin declarar sus get y set en Category por seguridad. Considero que es una aceptable solución de compromiso.

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 reunión o join.

 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.

@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, usaremos 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. Por consiguiente, 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.

Clase asociación

En ocasiones, relaciones que a priori son de tipo many-to-many no pueden configurarse como tales debido a la existencia de propiedades pertenecientes a la misma relación. Supongamos que el monto máximo de un presupuesto debe desglosarse para cada categoría en la que se aplica, lo que significa que cada asociación entre categoría y presupuesto debe almacenar esa cantidad. En consecuencia, necesitamos una clase que represente a la relación y contenga el atributo adecuado.

En UML, a estas clases que modelan el contenido de una relación se las denomina «clases asociación», y se enlazan con la relación mediante una línea discontinua

¿Qué hacemos con la clase BudgetCategory? Ahora no podemos configurar una relación many-to-many entre Category y Budget. En su lugar, tendremos relaciones bidireccionales de tipo many-to-one y one-to-many entre ambas clases y la nueva entidad BudgetCategory. El detalle más importante de la implementación de este nuevo modelo reside en el identificador de BudgetCategory: debe componerse de los identificadores de Budget y Category. Ya vimos cómo hacerlo. También nos beneficiaremos de la anotación @MapsId para que las columnas con los identificadores sean al mismo tiempo las claves ajenas.

@Embeddable
@Getter
@Setter
@EqualsAndHashCode
public class BudgetCategoryId implements Serializable {

    @Column(name = "category_id")
    private Long categoryId;
    @Column(name = "budget_id")
    private Long budgetId;

}

Nota: puesto que BudgetCategoryId no es una entidad, nos sirven los métodos hashCode y equals generados por Lombok.

@Getter
@Setter
@Entity
@Table(name = "budgets_categories")
public class BudgetCategory {

    @EmbeddedId
    private BudgetCategoryId id;

    @MapsId("budgetId")
    @ManyToOne
    private Budget budget;

    @MapsId("categoryId")
    @ManyToOne
    private Category category;

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

}
public class Budget {
    @OneToMany(mappedBy = "budget")
    private List<BudgetCategory> budgetCategories;

public class Category {
    @OneToMany(mappedBy = "category")
    private List<BudgetCategory> budgetCategories;

Aunque no sea necesaria la clase asociación, podemos aplicar la misma estrategia para crear una entidad que represente a la tabla intermedia. De hecho, algunos programadores nunca declaran asociaciones de tipo many-to-many. Dos son los motivos para evitarlas:

  • No tenemos el problema de borrado que veremos en la siguiente sección cuando usamos listas. Eso sí, la eliminación de la relación hay que hacerla manualmente en los dos extremos de la clase\entidad asociación.
  • La clase asociación puede emplearse en JPQL y Criteria API para operar con la tabla intermedia del mismo modo que lo haríamos con SQL y así escribir consultas más eficientes. Cuando trabajamos con relaciones many-to-many, Hibernate une las tres tablas implicadas aunque no sea necesario. Revisaremos un ejemplo concreto en un futuro capítulo.

Ordenación

Para las relaciones múltiples -y las colecciones de tipo @ElementCollection que veremos en el capítulo 29– 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 atributos separados por comas. Si no se indica ninguno, se usa el identificador en modo ASC.

En este ejemplo, la lista de gastos se ordena por su fecha, de la más reciente a la más antigua. Cuando varias fechas sean iguales, los gastos se ordenan según su cuantía.

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

Distinto es el funcionamiento de @OrderColumn, otro mecanismo de ordenación de JPA. Sirve para indicar el nombre de una columna de tipo numérico en la tabla que Hibernate debe usar para almacenar el orden de cada elemento en el listado. Cuando no se configure el nombre, se usa <nombre colección>_ORDER.

 @OneToMany(mappedBy = "category")
 @OrderColumn
 private List<Expense> expenses;

Hibernate se encarga de mantener la columna actualizada de forma transparente, de tal modo que siempre se usen números consecutivos comenzando por cero (0, 1, 2…). Esta indexación es específica para cada relación, motivo por el cual en la tabla anterior aparecen posiciones repetidas. Por ejemplo, tenemos dos posiciones cero, correspondientes a los gastos de la categoría uno y dos.

Así pues, la columna en realidad contiene el índice de la entidad en la colección, y si hay «huecos» en la numeración encontraremos un objeto nulo en esas posiciones. Esta situación no ocurrirá si delegamos en Hibernate la gestión de la columna. Si hubiera que realizar modificaciones directamente en los registros de la tabla con SQL, tengamos cuidado.

También debemos tener en cuenta que la presencia de nulos en la columna para una relación provocará la siguiente excepción cuando intentemos obtenerla.

org.hibernate.HibernateException: null index column for collection

En lo que respecta al rendimiento, el empleo de @OrderColumn puede ser un problema porque si insertamos o eliminamos una entidad que no sea la última, la reordenación de todas las posiciones requiere ejecutar una sentencia UPDATE para cada registro afectado.

update expenses set expenses_ORDER=? where id=?

Aparte del estándar JPA, en Hibernate hay dos opciones adicionales disponibles si las relaciones son de tipo Set o Map. Con @SortNatural indicamos que deben ordenarse según la implementación de Comparable que haga la entidad, mientras que @SortComparator nos permite aplicar un Comparator.

@OneToMany(mappedBy = "category")
@SortComparator(ExpenseComparator.class)
private SortedSet<Expense> expenses;

La ordenación realizada por @OrderBy es la más eficiente posible porque se lleva a cabo en la base de datos con la cláusula ORDER BY de SQL. Por su parte, con @OrderColumn las entidades se insertan en la posición de la lista que dictamine la columna que marca el orden. En las restantes estrategias, la ordenación es ligeramente menos eficiente porque se aplica «en memoria», esto es, sobre las entidades ya recuperadas desde la base de datos comparando los objetos. Y, en todos los casos, no perdamos de vista que la ordenación de las relaciones solo tiene sentido cuando sepamos de antemano que siempre la vamos a necesitar, requisito poco común. De no ser así, estaremos perdiendo tiempo haciendo una ordenación innecesaria.

Tipos de colecciones. Los Set.

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 el capítulo 29– 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. Con todo, esto no supone un gran escollo porque si establecemos un criterio de ordenación según lo explicado en la sección anterior, Hibernate recurre a una implementación de Set que mantiene el orden.

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

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 en memoria (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 esta prueba del proyecto jpa-entity creada un un capítulo anterior.

@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 de la línea 10 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, en nuestro caso expense y expenseDetached, sean considerados iguales, tendremos que implementar los métodos equals y hashCode. Esto nos garantiza, entre otros, la integridad de los Set.

La primera idea es recurrir al identificador de la entidad, ya que su cometido es identificarla de forma unívoca. Pero esta solución es inviable si la base de datos genera el identificador, pues en las entidades transient será nulo hasta que la entidad forme parte por primera vez de un contexto de persistencia. Esto implica que todas las entidades transient de la misma clase serán consideradas iguales porque comparten el identificador (es nulo), aunque representen a entes distintos.

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);
    }
}

En este ejemplo, también disponible en el proyecto jpa-entity, la elección de los atributos en los que basar la implementación de equals y hashCode 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 (ver 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.

(*) El mismo problema lo encontramos en las relaciones one-to-many unidireccionales con la opción orphanRemoval y cuando usamos @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]

Así pues, la eliminación de relaciones many-to-many es más eficiente si empleamos Set en vez List.

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 <<<<

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 )

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.