Tutorial: Mapear una asociación OneToMany entre tus entidades
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»Al terminar este tutorial vas a ser capaz de:
- Crear dos entidades nuevas de tu módulo, las dos extendiendo
BaseEntity. - Mapear una relación
@OneToMany/@ManyToOnebidireccional entre ellas, identificando cuál lado es el propietario. - Confirmar en la consola de H2 que la relación se tradujo en una llave foránea, no en una tabla intermedia, y que la base de datos la hace cumplir de verdad.
- Comprobar con un repository real qué hace
save()cuando la relación no llevacascade, y qué pasa al borrar un álbum que todavía tiene canciones. - Evitar el ciclo infinito que Lombok puede generar en una relación bidireccional.
Prerrequisitos: haber terminado el
Tutorial: Crear tu primera entidad JPA y verla en H2
(BaseEntity ya debe existir en tu proyecto), y haber leído
“Mapeo de las asociaciones”
en la teoría, en particular la sección @OneToMany / @ManyToOne.
2. Por qué esta es la siguiente pieza
Sección titulada «2. Por qué esta es la siguiente pieza»El tutorial anterior dejó una sola entidad, sin relación con nada. La mayoría de los módulos reales no son así: tu entidad principal casi siempre se relaciona con otras del mismo módulo. Este tutorial mapea esa primera relación, usando la cardinalidad más común: uno a muchos.
3. Crear las dos entidades, sin relación todavía
Sección titulada «3. Crear las dos entidades, sin relación todavía»Vas a usar Álbum y Canción como ejemplo: un álbum tiene muchas canciones, y cada canción pertenece a un único álbum. Si tu módulo no tiene estas entidades, adapta el ejemplo a dos entidades propias donde una sea el lado “uno” y la otra el lado “muchos”.
Dentro de la carpeta de tu módulo (artistas/), crea AlbumEntity.java:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.Entity;
import java.time.LocalDate;
import lombok.Data;
@Data@Entitypublic class AlbumEntity extends BaseEntity { private String titulo; private String fotoPortadaUrl; private LocalDate fechaLanzamiento;}Y CancionEntity.java:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.Entity;
import lombok.Data;
@Data@Entitypublic class CancionEntity extends BaseEntity { private String titulo; private String fotoPortadaUrl; private String duracion;}Por ahora son dos entidades independientes: ninguna sabe que la otra existe. Compila para confirmar que las dos, por separado, están bien escritas:
./mvnw compile4. Elegir el lado propietario
Sección titulada «4. Elegir el lado propietario»Antes de escribir la anotación, hay que decidir cuál lado guarda la llave
foránea. La teoría ya lo explica en detalle en
“@OneToMany / @ManyToOne”:
el lado con @ManyToOne es siempre el propietario.
Acá, cada canción pertenece a un único álbum, así que CancionEntity es el
lado “muchos” y el propietario: su tabla va a tener la columna album_id.
AlbumEntity es el lado inverso: declara la colección con mappedBy, y no
crea ninguna columna nueva.
5. Mapear la relación
Sección titulada «5. Mapear la relación»En AlbumEntity, agrega la colección de canciones:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.Entity;import jakarta.persistence.OneToMany;
import java.time.LocalDate;import java.util.ArrayList;import java.util.List;
import lombok.Data;
@Data@Entitypublic class AlbumEntity extends BaseEntity { private String titulo; private String fotoPortadaUrl; private LocalDate fechaLanzamiento;
@OneToMany(mappedBy = "album") private List<CancionEntity> canciones = new ArrayList<>();}En CancionEntity, agrega la referencia de vuelta al álbum:
package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.common.BaseEntity;
import jakarta.persistence.Entity;import jakarta.persistence.JoinColumn;import jakarta.persistence.ManyToOne;
import lombok.Data;import lombok.EqualsAndHashCode;import lombok.ToString;
@Data@Entitypublic class CancionEntity extends BaseEntity { private String titulo; private String fotoPortadaUrl; private String duracion;
@ManyToOne @JoinColumn(name = "album_id") @ToString.Exclude @EqualsAndHashCode.Exclude private AlbumEntity album;}Fíjate en cada pieza nueva:
mappedBy = "album"enAlbumEntity.cancionesnombra el atributo del lado propietario (CancionEntity.album), no una columna. Si lo escribes distinto al nombre real del atributo, Hibernate falla al arrancar.- Sin
cascade. Álbum y Canción no son una composición: una canción tiene vida propia (su propio CRUD en Release 7), así que cada entidad se guarda y se borra por su cuenta. Lo compruebas de verdad en el paso 7, con un repository de prueba; la teoría decascadeyorphanRemovalexplica cuándo sí conviene. @JoinColumn(name = "album_id")enCancionEntity.albumnombra explícitamente la columna de la llave foránea, siguiendo la misma convención quecascade. Con el atributo llamadoalbum, Hibernate ya generaalbum_idpor defecto, así que acá no cambia nada; se deja de todas formas porque es la convención del curso para toda relación intra-módulo, y no depende de que el nombre del atributo siempre coincida con el nombre de columna que se quiere.@ToString.Excludey@EqualsAndHashCode.Excludeevitan el ciclo que se explica en el paso 9, y hacen falta las dos: son anotaciones independientes, cada una controla un método generado distinto. Quitar solo una dejando la otra no alcanza; sin@ToString.Exclude, por ejemplo,toString()sigue entrando en el ciclo aunqueequals()/hashCode()ya estén arreglados.
6. Arrancar y verificar en H2
Sección titulada «6. Arrancar y verificar en H2»./mvnw spring-boot:runEn el log de arranque, busca la tabla cancion_entity: debe tener una
columna album_id, y no debe existir ninguna tabla intermedia (esa es
la diferencia con @ManyToMany, que sí la necesita).
Hibernate: create table cancion_entity ( album_id bigint, id bigint generated by default as identity, duracion varchar(255), foto_portada_url varchar(255), titulo varchar(255), primary key (id) )Hibernate: alter table if exists cancion_entity add constraint FK... foreign key (album_id) references album_entityAbre http://localhost:8080/h2-console y confirma lo mismo visualmente: la
tabla CANCION_ENTITY tiene ALBUM_ID, y la tabla ALBUM_ENTITY no
tiene ninguna columna nueva por la relación.
7. Probar qué hace save() sin cascade
Sección titulada «7. Probar qué hace save() sin cascade»cascade no es un comportamiento de la base de datos, así que la consola
de H2 no sirve para verlo: es JPA decidiendo, en tiempo de ejecución, qué
hacer con los objetos colgados de otro cuando llamas save(). Esta relación
no lleva cascade porque un álbum y sus canciones no son una
composición (la nota del
Paso 2 de Release 6
explica por qué). Para ver qué consecuencias tiene eso hace falta un
repository.
Crea AlbumRepository.java y CancionRepository.java (estas mismas
interfaces, sin cambios, son las que vas a necesitar en el siguiente
tutorial de esta serie, así que no son trabajo desechable):
package co.edu.uniandes.musical.artistas;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AlbumRepository extends JpaRepository<AlbumEntity, Long> {}package co.edu.uniandes.musical.artistas;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CancionRepository extends JpaRepository<CancionEntity, Long> {}Y una clase de prueba temporal que arma un álbum con dos canciones y lo guarda una sola vez, al arrancar la aplicación:
package co.edu.uniandes.musical.artistas;
import org.springframework.boot.CommandLineRunner;import org.springframework.stereotype.Component;
import java.util.List;
@Componentpublic class SaveCheckRunner implements CommandLineRunner {
private final AlbumRepository albumRepository; private final CancionRepository cancionRepository;
public SaveCheckRunner(AlbumRepository albumRepository, CancionRepository cancionRepository) { this.albumRepository = albumRepository; this.cancionRepository = cancionRepository; }
@Override public void run(String... args) { AlbumEntity album = new AlbumEntity(); album.setTitulo("Pies Descalzos");
CancionEntity c1 = new CancionEntity(); c1.setTitulo("Estoy Aquí"); c1.setAlbum(album);
CancionEntity c2 = new CancionEntity(); c2.setTitulo("Antología"); c2.setAlbum(album);
album.setCanciones(List.of(c1, c2));
AlbumEntity saved = albumRepository.save(album);
System.out.println("Album guardado con id: " + saved.getId()); System.out.println("Cancion 1 id: " + c1.getId()); System.out.println("Cancion 2 id: " + c2.getId()); }}Arranca la aplicación:
./mvnw spring-boot:runEn la salida de la consola deberías ver:
Album guardado con id: 1Cancion 1 id: nullCancion 2 id: nullEl álbum sí se guardó (saved.getId() no es null), pero las dos canciones
se quedaron con id: null: nunca llegaron a CANCION_ENTITY, aunque
estaban en la lista album.getCanciones(). No hay ninguna excepción que lo
avise: sin cascade, save() solo gestiona el objeto que le pasaste
directamente, no lo que cuelga de él en el grafo de objetos. Es un error
silencioso, más difícil de detectar que uno que sí truena.
Con una asociación simple, cada entidad se guarda con su propio repository.
Agrega estas dos líneas al final de run, después de guardar el álbum:
cancionRepository.save(c1);cancionRepository.save(c2);Vuelve a arrancar la aplicación: ahora Cancion 1 id y Cancion 2 id
tienen valor, y cada canción apunta al álbum que acabas de guardar.
Termina borrando SaveCheckRunner.java: era solo para esta prueba, y si lo
dejas va a insertar este álbum de ejemplo cada vez que arranques la
aplicación, incluido en los próximos pasos de este tutorial. Deja
AlbumRepository.java y CancionRepository.java donde están: esos sí son
los repositories reales que vas a seguir usando.
8. Insertar datos a través de la relación
Sección titulada «8. Insertar datos a través de la relación»En el panel de la derecha de la consola de H2, crea un álbum y algunas canciones apuntando a él:
INSERT INTO ALBUM_ENTITY (TITULO, FECHA_LANZAMIENTO) VALUES ('Pies Descalzos', '1995-02-27');SELECT * FROM ALBUM_ENTITY;Con el ID que te tocó (probablemente 1), agrega canciones de ese álbum:
INSERT INTO CANCION_ENTITY (TITULO, DURACION, ALBUM_ID) VALUES ('Estoy Aquí', '4:00', 1);INSERT INTO CANCION_ENTITY (TITULO, DURACION, ALBUM_ID) VALUES ('Antología', '4:45', 1);SELECT * FROM CANCION_ENTITY;Ahora prueba el caso que debería fallar: una canción con un ALBUM_ID que
no existe.
INSERT INTO CANCION_ENTITY (TITULO, DURACION, ALBUM_ID) VALUES ('Huérfana', '3:20', 999);Esto debe rechazarse con un error de violación de integridad
referencial. Si en cambio se inserta sin problema, revisa que
@JoinColumn esté en el lado @ManyToOne y que la aplicación haya
recreado la tabla (ddl-auto=create-drop la recrea en cada arranque).
9. Por qué @ToString.Exclude y @EqualsAndHashCode.Exclude
Sección titulada «9. Por qué @ToString.Exclude y @EqualsAndHashCode.Exclude»La teoría ya advierte sobre esto en
“Ciclos en relaciones bidireccionales”:
los métodos que Lombok genera pueden recorrer una relación bidireccional y
entrar en un ciclo infinito. Con AlbumEntity y CancionEntity no es
hipotético: si quitas las dos anotaciones y ejecutas
album.hashCode() sobre un álbum con canciones cuyo album apunta de
vuelta a él, el programa termina en StackOverflowError, porque
hashCode() de AlbumEntity incluye canciones, cuyo hashCode() de cada
CancionEntity incluye album, que vuelve a calcular el hashCode() de
AlbumEntity, indefinidamente.
@ToString.Exclude y @EqualsAndHashCode.Exclude sacan el atributo
album de esos métodos generados solo en CancionEntity: alcanza con
romper el ciclo en un solo lado, no hace falta excluir también canciones
en AlbumEntity.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes dos entidades de tu módulo relacionadas con
@OneToMany/@ManyToOne, con el lado propietario correcto, verificado en
cuatro niveles: el log de arranque, la consola de H2, una restricción de
integridad referencial que de verdad rechaza datos inválidos, y un
save() real que demuestra qué hace JPA cuando la relación no lleva
cascade. También te quedan AlbumRepository y CancionRepository, ya
escritos y sin nada desechable.
Lo que viene después, siguiendo el orden de Release 6:
- Repetir este patrón con las relaciones intra-módulo que falten en tu propio modelo.
- Mapear las relaciones inter-módulo, una vez que el equipo apruebe el diagrama de clases consolidado en la wiki.
- Convertir las demás interfaces
Repositoryde tu módulo enJpaRepository, el siguiente tutorial de esta serie.