Ir al contenido

Tutorial: Mapear una asociación OneToMany entre tus entidades

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/@ManyToOne bidireccional 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 lleva cascade, 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.


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
@Entity
public 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
@Entity
public 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:

Ventana de terminal
./mvnw compile

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.


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
@Entity
public 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
@Entity
public 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" en AlbumEntity.canciones nombra 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 de cascade y orphanRemoval explica cuándo sí conviene.
  • @JoinColumn(name = "album_id") en CancionEntity.album nombra explícitamente la columna de la llave foránea, siguiendo la misma convención que cascade. Con el atributo llamado album, Hibernate ya genera album_id por 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.Exclude y @EqualsAndHashCode.Exclude evitan 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 aunque equals()/hashCode() ya estén arreglados.

Ventana de terminal
./mvnw spring-boot:run

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

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


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;
@Component
public 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:

Ventana de terminal
./mvnw spring-boot:run

En la salida de la consola deberías ver:

Album guardado con id: 1
Cancion 1 id: null
Cancion 2 id: null

El á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.


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.


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 Repository de tu módulo en JpaRepository, el siguiente tutorial de esta serie.