Curso Jakarta EE 9 (27). JPA con Hibernate (10): Relaciones: operaciones en cascada

logo Jakarta EE

Las operaciones realizadas sobre una entidad actualizan sus relaciones (las claves ajenas) si es necesario, pero no se aplican a las entidades relacionadas. A veces, nos será de gran utilidad que los cambios de estado de una entidad también se realicen de forma automática en las entidades relacionadas.

>>>> ÍNDICE <<<<

Tipos de operaciones

Las operaciones en cascada de una relación son aquellas efectuadas en una entidad que deben ser aplicadas a las entidades relacionadas. JPA contempla cinco tipos distintos, referidos a la transición o cambio de estado de la entidad que contiene la relación.

Lo vemos más claro con código. Tenemos esta relación en el proyecto jpa-relations.

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

    @ManyToOne(optional = false)
    private Category category;

Queremos crear un gasto asociado a una nueva categoría.

 Expense expense = createExpense();
 Category category = new Category();
 category.setName("HOUSE");
 expense.setCategory(category);

 em.persist(expense);

Hibernate no lo permite, y nos indica con claridad el motivo.

java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: Not-null property references a transient value - transient instance must be saved before current operation : com.danielme.jakartaee.jpa.entities.Expense.category -> com.danielme.jakartaee.jpa.entities.Category

La categoría es una entidad transient que no existe en el contexto de persistencia, así que debemos pasarla a estado managed con el método persist antes de guardar el gasto. Todo esto lo expliqué en este capítulo.

 em.persist(category);
 em.persist(expense);

Aquí entra en acción la magia de las operaciones en cascada.

 @ManyToOne(optional = false, cascade = CascadeType.PERSIST)
 private Category category;

Se configuran en la anotación de la relación. El atributo cascade recibe todos los cambios de estado de la entidad Expense, definidos en el enumerado CascadeType, que deben aplicarse a la categoría asociada. Ahora, la operación persist del gasto se efectuará igualmente en la categoría, suponiendo, claro está, que sea necesario.

En la bitácora aparecen dos INSERT. Primero se guarda la categoría porque se necesita su identificador para establecer la clave ajena en el gasto.

insert into categories (color_hex, name) values (?, ?)
insert into expenses (amount, category_id, comments, concept, date) values (?, ?, ?, ?, ?)

El funcionamiento es equivalente para el resto de opciones. Veamos otro ejemplo.

  Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_1);
  Category category = em.find(Category.class, Datasets.CATEGORY_ID_FUEL);
  em.clear();

  category.setName("new category name");
  expense.setCategory(category);
        
  em.merge(expense);
  em.flush();

El código anterior cambia la categoría de un gasto en estado desligado. El merge del gasto actualiza su relación con la categoría, pero no las modificaciones realizadas en ella (línea 5).

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

Todo cambia si propagamos la operación merge.

 @ManyToOne(optional = false, cascade = { CascadeType.PERSIST, CascadeType.MERGE})
 private Category category;

Ahora, el merge del gasto provoca el merge de la categoría.

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

Las restantes operaciones en cascada son las siguientes.

  • refresh. Propaga el método refresh que «refresca» las entidades del contexto de persistencia con el contenido actual de la base de datos. Su uso, poco común, tiene sentido cuando creamos que puede haber cambios en la base de datos que no tenemos en las entidades managed.
  • detach. Desliga en cascada todas las entidades relacionadas.
  • remove. Borra las entidades relacionadas. Analizaremos este caso concreto en la próxima sección.
  • all. Aplica todos los tipos de operaciones. Es cómodo pero peligroso si se usa de forma indiscriminada porque incluye remove.

Borrado en cascada

Echemos un vistazo al remove en cascada. Sin esta opción, la eliminación de un gasto no implica el borrado de su categoría. Con ella, sí.

@ManyToOne(optional = false, cascade = CascadeType.REMOVE)
private Category category;

Tras este cambio, la siguiente acción causará una excepción.

Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_1);
em.remove(expense);

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`personal_budget`.`budgets_categories`, CONSTRAINT `FKlbfff2ay3xnctkbryqbuwhb2s` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`))

Tengamos presente las tablas.

En el juego de datos de pruebas del proyecto, el gasto 1 está asociado con la categoría 1, la cual a su vez está relacionada con un presupuesto. Al intentar borrar la categoría como consecuencia de la operación remove sobre el gasto, se incumple la restricción impuesta por la clave ajena que relaciona la categoría con el presupuesto (tabla budget_categories). Algo parecido sucederá si la categoría está asociada a más de un gasto. La realidad es que propagar el remove de Expense hacia Category no tiene sentido, y el ejemplo que he elegido es una muestra de las consecuencias de un uso equivocado de las operaciones en cascada, en especial remove.

Por tanto, las operaciones de borrado en cascada son peligrosas, y no solo porque puedan fallar. A fin de cuentas, las claves ajenas están para garantizar la integridad de los datos. Si el borrado no falla y, peor todavía, se propaga entre varias relaciones en cadena, las resultados pueden ser imprevisibles y causar pérdidas de datos indeseadas. Por ejemplo, podríamos ver que algunas categorías desaparecen misteriosamente del sistema.

Otro motivo por el que debemos ser cautelosos con remove es que Hibernate genera una sentencia DELETE por cada registro a eliminar. Si la relación consiste en un listado extenso, el borrado del conjunto tendrá un rendimiento pobre, y será mejor hacerlo con JPQL o SQL ejecutando una sentencia DELETE específica que elimine de una sola vez todos los registros.

Por último, me gustaría hacer una aclaración: la eliminación en cascada que hemos visto es realizada por Hibernate y no tiene nada que ver con la configuración ON DELETE CASCADE de las claves ajenas en las tablas que provoca el mismo comportamiento a nivel de base de datos. Si creamos la base de datos con Hibernate y queremos configurar este borrado, recurriremos a la anotación @OnDelete.

@OnDelete(action=OnDeleteAction.CASCADE)
@OneToMany
private List<InvoiceItem> items;

Cuando el borrado en cascada está definido en la tabla, no hace falta declararlo en la relación. De hecho, resulta contraproducente, pues es más eficiente que la operación sea realizada directamente por la base de datos.

Entidades huérfanas (orphan removal)

Existe un tipo de operación en cascada que se define de forma especial porque no se corresponde con un método del gestor de entidades. Se trata del borrado de entidades huérfanas: si una entidad desaparece de la relación, también se elimina de su tabla con el fin de evitar que quede desasignada (huérfana) con respecto al otro extremo de la relación. Esta operación se emplea en las asociaciones de cardinalidad 1 cuando la existencia de la entidad relacionada solo tiene sentido mientras pertenezca a la relación. Por este motivo, esta característica solo está disponible en @OneToMany y @ManyToOne.

Atención al este diagrama de clases.

Una factura (Invoice) se compone de una o más líneas de factura o elementos facturados (InvoiceItems). La existencia de una línea solo está justificada si pertenece a una factura; no tiene razón de ser que haya líneas que no pertenezcan a una factura. Si este hecho se produjera, tendríamos una entidad huérfana.

En UML, a este tipo de relaciones se les denomina «composición» y se indican en el diagrama con un rombo o diamante negro. Una clase compuesta, Invoice en el ejemplo, es propietaria en exclusiva de sus componentes (InvoiceItems). La eliminación de Invoice conlleva la destrucción definitiva de sus componentes.

Las entidades del ejemplo de la factura pueden modelarse del siguiente modo. Las asociamos con una relación bidireccional.

@Entity
@Table(name = "invoices")
@Getter
@Setter
public class Invoice {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "invoice")
    private List<InvoiceItem> items;

}
@Entity
@Table(name = "invoices_items")
@Getter
@Setter
public class InvoiceItem {

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

    @ManyToOne(optional = false)
    private Invoice invoice;

}

Suponiendo que tenemos una factura con varias líneas, con el siguiente código eliminamos una de la relación. Al ser bidireccional, modificamos los dos extremos.

Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
InvoiceItem invoiceItem = invoice.getItems().get(0);
invoice.getItems().remove(invoiceItem);
invoiceItem.setInvoice(null);
em.flush();

El resultado de este fragmento de código es que la clave ajena de invoiceItem se establece a null (línea 4) y tenemos un InvoiceItem huérfano. Situación que, por cierto, deberíamos impedir declarando la columna con la clave ajena como NOT NULL.

Para borrar la línea, tendremos que invocar a remove con invoiceItem. Pero es posible automatizar esta operación activando la opción orphanRemoval.

@OneToMany(mappedBy = "invoice", orphanRemoval = true)
private List<InvoiceItem> items;

Volviendo a ejecutar el código anterior, Hibernate borra la línea de la factura, asegurando la coherencia de los datos de la tabla invoice_items. Lo verifica esta prueba.

@DataSet(value = {"/datasets/invoices.yml"})
public class OrphanRemovalTest extends BaseRelationsTest {

    @Test
    void testDelete() {
        Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
        InvoiceItem invoiceItem = invoice.getItems().get(0);
        invoice.getItems().remove(invoiceItem);
        invoiceItem.setInvoice(null);

        em.flush();
        em.clear();

        assertThat(em.find(InvoiceItem.class, invoiceItem.getId()))
                .isNull();
        assertThat(em.find(Invoice.class, Datasets.INVOICE_ID).getItems().size())
                .isEqualTo(invoice.getItems().size());
    }

}

Algunas consideraciones a tener en cuenta.

  • No importa si la relación es unidireccional o, como en nuestro ejemplo, bidireccional.
  • Las entidades relacionadas solo se borrarán si están incluidas en el contexto de persistencia (es decir, su estado es managed)
  • La activación de orphanRemoval lleva implícita la operación de eliminación en cascada. Es evidente que si se borra una factura, hay que hacer lo propio con todas sus líneas para que no queden huérfanos.

Llegados a este punto, podemos deducir la diferencia entre declarar REMOVE y orphanRemoval. El primero borra todas las entidades relacionadas cuando se elimine la entidad con la operación en cascada. El segundo, además, también es capaz de borrar aquellas entidades que dejan de estar relacionadas.

Reflexión final

Podemos concluir que, normalmente, será conveniente habilitar todas las operaciones en cascada y el borrado de huérfanos en las relaciones de composición. En las demás asociaciones, lo habitual será optar por no declarar ninguna salvo en casos concretos y después de analizarlos con cuidado. De forma predeterminada nunca estableceremos operaciones en cascada a menos que tengamos claro que las necesitemos y seamos plenamente conscientes de su funcionamiento.

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 )

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.