Curso Jakarta EE 9 (29). JPA con Hibernate (12): Relaciones especiales

logo Jakarta EE

Hemos examinado las posibles relaciones entre entidades que podemos establecer, así como su configuración (modo de carga y operaciones en cascada). No obstante, se han quedado en el tintero algunas configuraciones alternativas a las relaciones, de uso poco habitual, pero muy interesantes. Revisaremos tres de ellas en este pequeño capítulo.

>>>> ÍNDICE <<<<

Tablas secundarias

Se tiende a asumir que una entidad se corresponde con una tabla (o una vista, en la práctica es lo mismo). Esto es cierto para la inmensa mayoría de modelos de datos diseñados con JPA. Pero podemos encontrar bases de datos en las que la información que de forma lógica debería pertenecer a una entidad está repartida en más de una tabla, quizás como consecuencia de una medida de optimización. Esto implica que una entidad puede tener algunos atributos en tablas distintas de la que tiene asociada.

La situación anterior tan particular está contemplada por JPA gracias a la anotación @SecondaryTable y a la inclusión del atributo opcional table en @Column y @JoinColumn. Vamos a recuperar un ejemplo del proyecto jpa-relations.

Se trata de una relación one-to-one unidireccional en la que cada entidad cuenta con su propia tabla.

Es posible eliminar la entidad Preferences y seguir trabajando con las dos tablas del siguiente modo.

@Entity
@Table(name = "users")
@SecondaryTable(name = "preferences",
        pkJoinColumns = @PrimaryKeyJoinColumn(name = "user_id", referencedColumnName = "id"))
@Getter
@Setter
public class UserAndPreference {

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

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

    @Column(table = "preferences")
    private boolean enableNotifications;

    @Column(table = "preferences")
    private boolean enableSharing;
}

No basta con indicar el nombre de la tabla en la columna, hay que registrarla a nivel de clase con @SecondaryTable (podemos declarar todas las tablas que necesitemos repitiendo la anotación). La relación entre la tabla de la entidad y la secundaria se establece por una clave ajena de la segunda hacia la primera. De forma predeterminada, se supone que la columna con la clave ajena tiene el mismo nombre que la columna de la clave primaria a la que referencia. No es nuestro caso (se llama user_id), así que la he configurado con pkJoinColumns, un array que permite el uso de claves primarias múltiples.

Nada cambia si empleamos una clase incrustable para agrupar los datos de las tablas secundarias.

@Entity
@Table(name = "users")
@SecondaryTable(name = "preferences",
        pkJoinColumns = @PrimaryKeyJoinColumn(name = "user_id", referencedColumnName = "id"))
@Getter
@Setter
public class UserAndPreference {

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

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

    private PreferenceEmbeddable preferences;

}
@Embeddable
@Getter
@Setter
public class PreferenceEmbeddable {

    @Column(table = "preferences")
    private boolean enableNotifications;

    @Column(table = "preferences")
    private boolean enableSharing;

}

Hibernate obtiene la entidad al completo con una reunión entre las tablas users y preferences. Se asegura que el usuario siempre se recupere tenga o no un registro asociado con sus preferencias, de ahí que la reunión sea externa (users left outer join preferences).

select
	userandpre0_.id as id1_12_0_,
	userandpre0_.name as name2_12_0_,
	userandpre0_1_.enableNotifications as enableNo1_10_0_,
	userandpre0_1_.enableSharing as enableSh2_10_0_
from
	users userandpre0_
left outer join preferences userandpre0_1_ on
	userandpre0_.id = userandpre0_1_.user_id
where
	userandpre0_.id =?

Así pues, estamos ante un remedo de one-to-one con obtención inmediata en la que la entidad relacionada, que sería Preferences, no existe en el modelo.

Colecciones con @ElementCollection

Con asociaciones de tipo one-to-many y many-to-many modelamos colecciones de entidades. ¿Y si tenemos objetos que no lo son? Por ejemplo, una lista de cadenas. Aquí entra en acción la anotación @ElementCollection que permite declarar como atributos persistentes colecciones de objetos pertenecientes a tipos básicos o a clases incrustables. Veamos el primer caso.

@Entity
@Table(name = "contact_info")
@Getter
@Setter
public class ContactInfo {

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

    @Column(nullable = false, unique = true)
    private String mainEmail;

    @ElementCollection
    private List<String> additionalEmails;

}

La clase ContactInfo es una entidad que representa datos de contacto. Estos datos requieren un email, y se da la posibilidad de añadir varios adicionales, modelados con un listado de cadenas. Si bien no tenemos una entidad con los emails adicionales, sea cual sea el motivo de esta decisión, se guardan en una tabla con la correspondiente clave ajena. Así es como Hibernate espera que sea.

El nombre de la tabla y su clave ajena son personalizables con @CollectionTable. La columna que almacena la cadena también puede configurarse con @Column.

@ElementCollection
@CollectionTable(name = "contact_additional_emails",
            joinColumns = @JoinColumn(name = "contact_info"))
@Column(name = "email", nullable = false, unique = true)
private List<String> additionalEmails;

Si los elementos de la colección tienen más de un atributo, no es ningún problema: los modelamos con una clase incrustable.

 @ElementCollection
 @CollectionTable(name = "contact_additional_emails",
            joinColumns = @JoinColumn(name = "contact_info"))
 private List<AdditionalEmail> additionalEmails;
@Embeddable
@Getter
@Setter
public class AdditionalEmail {

    @Column(nullable = false, unique = true)
    private String email;
    private boolean enabled = true;
    
}

Así pues, hemos implementado una suerte de one-to-many unidireccional que no usa entidades y exhibe el mismo funcionamiento en lo que al modo de obtención se refiere: es «perezoso» o LAZY de forma predeterminada. Esto lo cambiamos configurando el fetch en @ElementCollection. Con respecto a las operaciones en cascada, el comportamiento es equivalente a ALL y orphanRemoval. Además, es posible utilizar los mecanismos de ordenación de las relaciones múltiples tratados en el capítulo 25.

Con todo, esta «relación» tan peculiar no es del todo satisfactoria. Ejecutemos la siguiente prueba, preparada para el ejemplo basado en la clase incrustable AdditionalEmail.

@Test
void testAddEmail() {
    ContactInfo contactInfo = em.find(ContactInfo.class, Datasets.CONTACT_INFO_ID_1);
        
    contactInfo.getAdditionalEmails().add(new AdditionalEmail("mail4", false));
    em.flush();
}

En el dataset contacts.yml hay una información de contacto con tres emails adicionales. Las sentencias SQL generadas demuestran que la estrategia de sincronización del listado con la tabla es radical: se eliminan todos los registros para el contacto y se insertan los presentes en la lista. En consecuencia, aparece un INSERT para cada correo, lo que supone una mala noticia desde el punto de vista de la eficiencia; la realidad es que solo hace falta insertar el nuevo email. Hibernate actúa así porque la lista no contiene entidades con su identificador.

DELETE from contact_additional_emails where contact_info=?
INSERT INTO contact_additional_emails (contact_info, email, enabled) VALUES (?, ?, ?)
INSERT INTO contact_additional_emails (contact_info, email, enabled) VALUES (?, ?, ?)
INSERT INTO contact_additional_emails (contact_info, email, enabled) VALUES (?, ?, ?)
INSERT INTO contact_additional_emails (contact_info, email, enabled) VALUES (?, ?, ?)

Por tanto, es una mala idea utilizar @ElementCollection cuando la colección sea grande y se actualice con frecuencia. En general, recomiendo crear una entidad y usar many-to-one \ one-to-many.

Map

JPA es muy flexible en lo que respecta al uso de Map, ya que puede contener una relación múltiple o bien una colección de objetos de tipo @ElementCollection. Su utilidad es evidente: accedemos de forma inmediata y eficiente a cualquier elemento de la colección a partir de un valor o clave que lo identifica unívocamente. No puede haber dos elementos con una misma clave.

Probemos a convertir uno de las relaciones one-to-many del proyecto de ejemplo en un Map. En concreto, la lista de líneas pertenecientes a una factura.

@Entity
@Table(name = "invoices")
@Getter
@Setter
public class Invoice {

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

    @OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, orphanRemoval = true)
    private Map<Long, InvoiceItem> items;

Falta un pequeño detalle porque, al obtenerse el Map, Hibernate no sabe qué columna contiene su clave. Se lanzará una excepción.

org.hibernate.HibernateException: null index column for collection: com.danielme.jakartaee.jpa.entities.cascade.Invoice.items

	at org.hibernate@5.3.20.Final//org.hibernate.persister.collection.AbstractCollectionPersister.readIndex(AbstractCollectionPersister.java:856)
	at org.hibernate@5.3.20.Final//org.hibernate.collection.internal.PersistentMap.readFrom(PersistentMap.java:292)

Lo solucionamos informando con @MapKey el nombre del campo de la entidad relacionada que sirve de clave del Map. No tiene por qué ser su identificador, nos vale cualquier atributo que funcione a modo de clave natural (único y no nulo).

 @OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, orphanRemoval = true)
 @MapKey(name = "id")
 private Map<Long, InvoiceItem> items;

En el ejemplo anterior, no sería necesario proporcionar el nombre del atributo porque es el identificador.

La configuración cambia ligeramente cuando el Map, en lugar de entidades, contiene objetos de tipos básicos o clases incrustables. Veamos el socorrido y manido ejemplo del carrito de la compra.

@Entity
@Table(name = "carts")
@Getter
@Setter
public class ShoppingCart {

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

    @ElementCollection
    @CollectionTable(name = "carts_products",
            joinColumns = @JoinColumn(name = "cart_id"))
    @MapKeyColumn(name = "reference")
    private Map<String, Product> products;

}
@Embeddable
@Getter
@Setter
public class Product {

    @Column(nullable = false)
    private LocalDate added;

    @Column(nullable = false)
    private Integer quantity;
}

Tenemos un carrito de la compra cuyos artículos están en products. La colección se configura, tal y como vimos líneas atrás, con @ElementCollection y @CollectionTable. De nuevo, con el Map hay que especificar la columna que se usará como su clave y que en el ejemplo es el código o referencia del producto. En esta ocasión, se aplica la anotación @MapKeyColumn. Esta columna y la que representa al carrito (cart_id) forman una clave primaria compuesta para la tabla carts_products que garantiza la unicidad de un producto en una cesta de la compra, permitiendo que un producto forme parte de muchos carritos la vez.

La configuración es la misma si en lugar de una clase incrustable tuviéramos un tipo básico. En la siguiente versión de la entidad, tenemos un Map con el precio de cada producto.

@ElementCollection
@CollectionTable(name = "carts_products",
        joinColumns = @JoinColumn(name = "cart_id"))
@MapKeyColumn(name = "product_reference")
@Column(name = "price")
private Map<String, BigDecimal> products;

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 )

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.