Tutorial: Mapear una asociación ManyToMany
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Mapear una relación
@ManyToManybidireccional entre dos entidades de tu módulo, identificando cuál lado es el propietario. - Confirmar en el log de arranque que Hibernate creó una tabla intermedia y no una llave foránea en ninguna de las dos tablas.
- Comprobar con un repository real que solo el lado propietario guarda la relación.
- Ver qué pasa al borrar un artista y al borrar un álbum que todavía tiene artistas.
Prerrequisitos: haber terminado el
Tutorial: Mapear una asociación OneToMany
(tienes AlbumEntity, CancionEntity y AlbumRepository) y el
Tutorial: Convertir tu repository a JpaRepository
(tu ArtistaRepository ya es un JpaRepository). También conviene haber
leído la sección
@ManyToMany de la
teoría.
2. Por qué @ManyToMany
Sección titulada «2. Por qué @ManyToMany»Un artista graba muchos álbumes, y un álbum puede ser una colaboración entre varios artistas: ninguno de los dos lados tiene una única contraparte. Por eso ninguna de las dos tablas puede guardar la llave foránea de la otra: tendría que repetir la fila una vez por cada artista o álbum asociado. La solución es una tabla intermedia con una fila por cada pareja artista-álbum.
3. Elegir el lado propietario
Sección titulada «3. Elegir el lado propietario»En una relación @ManyToMany hay que decidir qué entidad declara la tabla
intermedia: es el lado propietario, el único que JPA mira para guardar la
relación (la teoría lo explica en
¿Bidireccional o unidireccional? El dueño de la asociación).
En este tutorial el propietario es ArtistaEntity, y AlbumEntity es el lado
inverso. Es una convención, no una obligación: lo que sí importa es que el
equipo lo acuerde y que todos usen el mismo lado.
4. Mapear la relación
Sección titulada «4. Mapear la relación»En ArtistaEntity, agrega la colección de álbumes. Esta es la clase completa,
con las piezas nuevas marcadas:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.CollectionTable;import jakarta.persistence.Column;import jakarta.persistence.ElementCollection;import jakarta.persistence.Entity;import jakarta.persistence.JoinColumn;import jakarta.persistence.JoinTable; // nuevoimport jakarta.persistence.ManyToMany; // nuevo
import java.time.LocalDate;import java.util.ArrayList; // nuevoimport java.util.List; // nuevoimport java.util.Set;
import lombok.Data;import lombok.EqualsAndHashCode; // nuevoimport lombok.ToString; // nuevo
@Data@Entitypublic class ArtistaEntity extends BaseEntity {
private String nombre; private String fotoArtistaUrl; private String paisOrigen; private LocalDate fechaNacimiento;
@Column(length = 1000) private String biografia;
@ElementCollection @CollectionTable( name = "artista_generos", joinColumns = @JoinColumn(name = "artista_id") ) @Column(name = "genero") private Set<String> generos;
// nuevo: lado propietario de la relación @ManyToMany @JoinTable( name = "artista_album", joinColumns = @JoinColumn(name = "artista_id"), inverseJoinColumns = @JoinColumn(name = "album_id") ) @ToString.Exclude @EqualsAndHashCode.Exclude private List<AlbumEntity> albumes = new ArrayList<>();}En AlbumEntity, agrega el lado inverso:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.Entity;import jakarta.persistence.ManyToMany; // nuevoimport jakarta.persistence.OneToMany;
import java.time.LocalDate;import java.util.ArrayList;import java.util.List;
import lombok.Data;import lombok.EqualsAndHashCode; // nuevoimport lombok.ToString; // nuevo
@Data@Entitypublic class AlbumEntity extends BaseEntity { private String titulo; private String fotoPortadaUrl; private LocalDate fechaLanzamiento;
@OneToMany(mappedBy = "album") private List<CancionEntity> canciones = new ArrayList<>();
// nuevo: lado inverso de la relación @ManyToMany(mappedBy = "albumes") @ToString.Exclude @EqualsAndHashCode.Exclude private List<ArtistaEntity> artistas = new ArrayList<>();}Fíjate en cada pieza nueva:
@JoinTable(name = "artista_album", ...)en el lado propietario nombra la tabla intermedia y sus dos llaves foráneas:artista_id(haciaARTISTA_ENTITY, enjoinColumns) yalbum_id(haciaALBUM_ENTITY, eninverseJoinColumns). Sin@JoinTable, JPA igual crea la tabla, pero con un nombre por defecto; declararla sirve para que coincida con lo que el equipo acordó en el diagrama.mappedBy = "albumes"enAlbumEntity.artistasnombra el atributo del lado propietario (ArtistaEntity.albumes), no una columna. Si lo escribes distinto al nombre real del atributo, Hibernate falla al arrancar.= new ArrayList<>()en las dos colecciones evita unNullPointerExceptional hacergetAlbumes().add(...)sobre una entidad recién creada.@ToString.Excludey@EqualsAndHashCode.Excludeen las dos colecciones, por la misma razón del paso 9 del tutorial de@OneToMany: cada lado describiría al otro sin fin.
Compila para confirmar que todo está bien escrito:
./mvnw compile5. Arrancar y verificar en H2
Sección titulada «5. Arrancar y verificar en H2»./mvnw spring-boot:runEn el log de arranque aparece una tabla nueva, artista_album, que solo tiene
las dos llaves foráneas:
Hibernate: create table artista_album ( album_id bigint not null, artista_id bigint not null )Hibernate: alter table if exists artista_album add constraint FK... foreign key (album_id) references album_entityHibernate: alter table if exists artista_album add constraint FK... foreign key (artista_id) references artista_entityAbre http://localhost:8080/h2-console y confirma lo mismo visualmente:
- Existe la tabla
ARTISTA_ALBUMcon las columnasALBUM_IDyARTISTA_ID. ARTISTA_ENTITYyALBUM_ENTITYno tienen ninguna columna nueva por la relación: es la diferencia con@OneToMany/@ManyToOne, donde la llave foránea vive en una de las dos tablas.
Si ahora pides http://localhost:8080/artistas, la respuesta ya incluye el
campo albumes (una lista vacía mientras no haya filas en ARTISTA_ALBUM).
ModelMapper lo copia solo, porque la entidad y el DTO tienen el atributo con
el mismo nombre y sus tipos anidados también coinciden (AlbumEntity con
AlbumDTO, CancionEntity con CancionDTO).
Detén la aplicación con Ctrl+C.
6. Probar qué lado guarda la relación
Sección titulada «6. Probar qué lado guarda la relación»Una relación bidireccional se puede modificar desde los dos lados en Java,
pero JPA solo guarda lo que ve en el lado propietario. Se comprueba con una
clase de prueba temporal. Crea RelCheckRunner.java:
package co.edu.uniandes.musical.artistas;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.boot.CommandLineRunner;import org.springframework.dao.DataIntegrityViolationException;import org.springframework.stereotype.Component;
@Componentpublic class RelCheckRunner implements CommandLineRunner {
private final ArtistaRepository artistaRepository; private final AlbumRepository albumRepository; private final EntityManagerFactory entityManagerFactory;
public RelCheckRunner(ArtistaRepository artistaRepository, AlbumRepository albumRepository, EntityManagerFactory entityManagerFactory) { this.artistaRepository = artistaRepository; this.albumRepository = albumRepository; this.entityManagerFactory = entityManagerFactory; }
/** Cuenta las filas de la tabla intermedia directamente en la base de datos. */ private Object filasDeLaTablaIntermedia() { return entityManagerFactory.createEntityManager() .createNativeQuery("select count(*) from artista_album") .getSingleResult(); }
@Override public void run(String... args) { AlbumEntity album = new AlbumEntity(); album.setTitulo("Pa'lla Voy"); albumRepository.save(album);
ArtistaEntity artista = new ArtistaEntity(); artista.setNombre("Marc Anthony"); artista.getAlbumes().add(album); artistaRepository.save(artista); System.out.println("1. Agregar por el lado propietario: filas = " + filasDeLaTablaIntermedia());
AlbumEntity otroAlbum = new AlbumEntity(); otroAlbum.setTitulo("La Reina Del Merengue"); albumRepository.save(otroAlbum);
ArtistaEntity otroArtista = new ArtistaEntity(); otroArtista.setNombre("Datty"); artistaRepository.save(otroArtista);
otroAlbum.getArtistas().add(otroArtista); albumRepository.save(otroAlbum); System.out.println("2. Agregar por el lado inverso: filas = " + filasDeLaTablaIntermedia());
try { albumRepository.delete(album); System.out.println("3. Borrar un álbum con artistas: se borró"); } catch (DataIntegrityViolationException e) { System.out.println("3. Borrar un álbum con artistas: no se puede"); }
artistaRepository.delete(artista); System.out.println("4. Borrar el artista: filas = " + filasDeLaTablaIntermedia()); }}Arranca la aplicación y busca estas líneas en la consola:
1. Agregar por el lado propietario: filas = 12. Agregar por el lado inverso: filas = 13. Borrar un álbum con artistas: no se puede4. Borrar el artista: filas = 0Qué significa cada una:
- Agregar por el lado propietario guarda la relación. Al agregar el álbum
a
artista.getAlbumes()y guardar el artista, apareció una fila enARTISTA_ALBUM. - Agregar solo por el lado inverso no guarda nada.
otroAlbum.getArtistas().add(...)cambió la lista en memoria, pero la tabla intermedia sigue con una sola fila, sin ningún error que lo avise. Es el mismo error silencioso que ya viste concascade: para relacionar dos entidades, siempre se modifica el lado propietario. - Un álbum con artistas no se puede borrar. La fila de
ARTISTA_ALBUMapunta a él, y la llave foránea lo impide. El lado inverso no limpia la tabla intermedia por ti. - Borrar el artista sí limpia la tabla intermedia. Al borrar al
propietario, JPA borra primero sus filas de
ARTISTA_ALBUMy los álbumes quedan intactos:@ManyToManyno es una composición, y por eso no llevacascade(ver la nota del Paso 2 de Release 6).
Cuando termines, borra RelCheckRunner.java: era solo para esta prueba, y si
lo dejas va a ejecutarse cada vez que arranques la aplicación.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
ArtistaEntityyAlbumEntityrelacionadas con@ManyToMany, conArtistaEntitycomo lado propietario y la tabla intermediaartista_album.- La comprobación, con un repository real, de que solo el lado propietario guarda la relación.
GET /artistaslisto para devolveralbumes.
Lo que viene después, siguiendo el orden de Release 6:
- Cargar datos iniciales con
data.sql, incluidas las filas deartista_album. - Mapear las relaciones inter-módulo, una vez que el equipo apruebe el diagrama del proyecto.