Ir al contenido

Persistencia con JPA (2): mapeo de asociaciones

En la primera parte una entidad se mapeaba a una tabla y se guardaba con un repository. Pero las entidades casi nunca viven solas: se relacionan entre sí. Esta página muestra cómo JPA mapea esas asociaciones (cardinalidad, dirección, cascade, momento de carga) y cómo el contexto de persistencia vigila los objetos mientras se guardan y se modifican.

La Figura 1 de la primera parte solo mostró un sentido de navegación: empleado.getDepartment(). Pero en el modelo de objetos también tiene sentido navegar al revés, que un departamento conozca a sus empleados (departamento.getEmployees()), y ahí vuelve a aparecer el desajuste objeto-relacional: en OO basta con agregar otro atributo, una colección; en el modelo relacional no hay una segunda llave foránea que agregar, porque la relación ya vive en una sola columna.

Mapear una asociación del modelo de dominio a JPA implica tomar tres decisiones:

  1. Cardinalidad: ¿uno a uno, uno a muchos, o muchos a muchos?
  2. ¿Bidireccional o unidireccional?: ¿el código necesita navegar la relación desde las dos entidades, o solo desde una?
  3. ¿Asociación simple o composición?: ¿las dos entidades existen de forma independiente, o una no tiene sentido sin la otra?

El resto de esta sección recorre cada una de esas decisiones con ejemplos.

JPA ofrece una anotación distinta según cuántas instancias participan de cada lado de la asociación.

@OneToOne anota un atributo que hace referencia a una única instancia de otra entidad, cuando esa otra entidad también tiene a lo sumo una instancia de la primera. Un lado es propietario (contiene la llave foránea) y el otro es inverso (usa mappedBy).

Cada ciudad tiene un único alcalde, y cada ciudadano administra a lo sumo una ciudad:

@Entity
public class CityEntity extends BaseEntity {
private String name;
@OneToOne
private CitizenEntity mayor;
}
@Entity
public class CitizenEntity extends BaseEntity {
private String name;
private String address;
@OneToOne(mappedBy = "mayor")
private CityEntity manage;
}

CityEntity es el lado propietario: CITY_ENTITY contiene la columna MAYOR_ID. CitizenEntity.manage es el lado inverso, no crea columna, por eso usa mappedBy = "mayor", el nombre del atributo del otro lado.

erDiagram
    CITY_ENTITY |o--o| CITIZEN_ENTITY : mayor
    CITY_ENTITY {
        bigint id PK
        varchar name
        bigint mayor_id FK
    }
    CITIZEN_ENTITY {
        bigint id PK
        varchar name
        varchar address
    }

En una relación uno a muchos, el lado con @ManyToOne es propietario y contiene la llave foránea. El lado inverso usa mappedBy y no crea una columna adicional.

// DepartmentEntity: lado inverso
@OneToMany(mappedBy = "department")
private List<EmployeeEntity> employees;
// EmployeeEntity: lado propietario
@ManyToOne
private DepartmentEntity department;

La tabla EMPLOYEE_ENTITY contiene DEPARTMENT_ID. El mismo patrón se aplica a CompanyEntity y DepartmentEntity: una compañía tiene muchos departamentos y cada departamento pertenece a una compañía.

erDiagram
    DEPARTMENT_ENTITY |o--o{ EMPLOYEE_ENTITY : employees
    DEPARTMENT_ENTITY {
        bigint id PK
        varchar name
    }
    EMPLOYEE_ENTITY {
        bigint id PK
        varchar name
        double salary
        bigint department_id FK
    }

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. Aquí ninguna de las dos tablas puede contener la llave foránea de la otra: una fila tendría que repetirse por cada artista o álbum asociado, así que JPA necesita una tabla intermedia, con una fila por cada pareja artista-álbum.

@Entity
public class ArtistaEntity extends BaseEntity {
private String nombre;
@ManyToMany
@JoinTable(
name = "artista_album",
joinColumns = @JoinColumn(name = "artista_id"),
inverseJoinColumns = @JoinColumn(name = "album_id")
)
private List<AlbumEntity> albumes = new ArrayList<>();
}
@Entity
public class AlbumEntity extends BaseEntity {
private String titulo;
@ManyToMany(mappedBy = "albumes")
private List<ArtistaEntity> artistas = new ArrayList<>();
}

ArtistaEntity es el lado propietario: declara @JoinTable con el nombre de la tabla intermedia (artista_album) y sus dos llaves foráneas, artista_id (hacia ArtistaEntity, en joinColumns) y album_id (hacia AlbumEntity, en inverseJoinColumns). AlbumEntity.artistas es el lado inverso, con mappedBy igual que en las relaciones uno a muchos. La diferencia es que aquí ninguno de los dos lados tiene la llave foránea: ambas viven en artista_album.

Sin @JoinTable, JPA igual crea la tabla intermedia, con un nombre y columnas por defecto; declararla explícitamente es la misma decisión que ya se vio con @Table/@Column: usar los valores por defecto salvo que haga falta un nombre puntual, por ejemplo, para que coincida con lo que el equipo ya acordó en la tabla de asociaciones del proyecto.

erDiagram
    ARTISTA_ENTITY ||--o{ ARTISTA_ALBUM : ""
    ALBUM_ENTITY ||--o{ ARTISTA_ALBUM : ""
    ARTISTA_ENTITY {
        bigint id PK
        varchar nombre
    }
    ALBUM_ENTITY {
        bigint id PK
        varchar titulo
    }
    ARTISTA_ALBUM {
        bigint artista_id FK
        bigint album_id FK
    }

Ninguna de las dos entidades contiene la llave foránea de la otra: las dos viven juntas en ARTISTA_ALBUM.

¿Bidireccional o unidireccional? El dueño de la asociación

Sección titulada «¿Bidireccional o unidireccional? El dueño de la asociación»

Todos los ejemplos anteriores son bidireccionales: las dos entidades se referencian mutuamente, una con mappedBy y la otra con la llave foránea. Eso es una decisión, no una obligación de JPA: la relación también puede ser unidireccional, donde solo una entidad declara el atributo y la otra no sabe nada de la relación en el código Java, aunque la llave foránea siga existiendo igual en la base de datos.

Por ejemplo, si el código nunca necesita departamento.getEmployees() y solo consulta empleados por su departamento con el repository, DepartmentEntity no necesita declarar nada:

// EmployeeEntity: única entidad que conoce la relación
@ManyToOne
private DepartmentEntity department;
// DepartmentEntity no declara ningún atributo de esta relación

DEPARTMENT_ID sigue existiendo en EMPLOYEE_ENTITY exactamente igual; lo único que cambia es que, en Java, la relación solo se navega en un sentido.

Elegir entre bidireccional y unidireccional es un balance:

  • Bidireccional permite navegar en ambos sentidos desde el código, pero cuesta más mantenerla: hay que actualizar los dos lados a mano, como se ve a continuación, y corre el riesgo de ciclos en toString()/equals()/hashCode() (ver más adelante).
  • Unidireccional es más simple y no tiene ninguno de esos dos riesgos, pero limita la navegación a un solo sentido: si más adelante hace falta el otro sentido, hay que agregar el atributo que falta y decidir de nuevo el lado propietario.

La recomendación es declarar solo el sentido que el código realmente necesita navegar, y agregar el otro únicamente cuando aparezca esa necesidad.

En una relación bidireccional, además, hay que recordar cuál de los dos lados es el que JPA usa para guardar la llave foránea: el propietario. Si el código solo actualiza el lado inverso, la relación nunca llega a la base de datos.

¿De qué lado hago el add()?

Department-Employee: agregar solo por el lado inverso no guarda la relación.

Entrada al serviciodepartment.getEmployees().add(employee)
Estado previo de la BDemployee.department es null; no hay fila todavía para employee
Resultado esperadoDEPARTMENT_ID queda en NULL: la relación no se guardó
AplicaciónPaso 1 de 3
Se agrega por el lado inverso

department.getEmployees() es el lado mappedBy: agregar aquí solo cambia la lista en memoria de department.

department.getEmployees().add(employee);

En el modelo de dominio, una ciudad registra muchos carros, pero un carro se registra en una sola ciudad. Es una asociación simple: ciudad y carro existen de forma independiente uno del otro. Se puede crear un carro sin asignarle ciudad, o una ciudad sin ningún carro registrado.

Esa independencia se traduce en dos asociaciones de cardinalidad OneToMany/ManyToOne, igual que en Department–Employee, pero sin ser obligatoria: city en CarEntity puede quedar en null.

@Entity
public class CityEntity extends BaseEntity {
private String name;
@OneToMany(mappedBy = "city")
private List<CarEntity> cars = new ArrayList<>();
}
@Entity
public class CarEntity extends BaseEntity {
private String plate;
private Integer model;
@ManyToOne
private CityEntity city;
}

En el modelo de dominio, un ciudadano tiene varias licencias de conducción (una por categoría, por ejemplo), pero una licencia de conducción le pertenece a un solo ciudadano. A diferencia de la asociación simple anterior, esta es una composición (igual que Book–Review al diseñar el API): no tiene sentido crear una licencia de conducción de forma aislada, sin asociarla a un ciudadano.

La cardinalidad es la misma que en la asociación simple, OneToMany/ManyToOne, pero esta vez la dependencia de vida entre las dos entidades sí importa:

@Entity
public class CitizenEntity extends BaseEntity {
private String name;
@OneToMany(
mappedBy = "citizen",
cascade = CascadeType.ALL,
fetch = FetchType.EAGER,
orphanRemoval = true
)
private List<DriverLicenseEntity> driverLicenses = new ArrayList<>();
}
@Entity
public class DriverLicenseEntity extends BaseEntity {
private String category;
private LocalDate dueDate;
@ManyToOne
private CitizenEntity citizen;
}

Además de mappedBy, esta relación declara tres atributos que la asociación simple no necesitaba: cascade, fetch = FetchType.EAGER (una licencia casi siempre se necesita junto con su ciudadano, así que conviene cargarla de una vez) y orphanRemoval.

@OneToMany no decide por sí solo qué pasa con los objetos dependientes cuando se guarda o se borra el propietario. La asociación simple Ciudad–Carro no necesitó nada más: como ambos existen de forma independiente, cada uno se guarda y se borra por su cuenta, con su propio save()/deleteById(). La composición Ciudadano–Licencia sí lo necesita:

  • cascade = CascadeType.ALL propaga el save() y el borrado del ciudadano a sus licencias: no hace falta guardarlas ni borrarlas una por una.
  • orphanRemoval = true borra una licencia si deja de estar en la lista de su ciudadano, aunque el ciudadano siga existiendo.
Atributo Qué controla
mappedBy marca el lado inverso; el atributo nombrado es el lado propietario.
fetch cuándo se carga la colección (LAZY o EAGER).
cascade si operaciones sobre el propietario (guardar, borrar) se propagan al dependiente.
orphanRemoval borra automáticamente un dependiente que deja de estar referenciado por el propietario.

El atributo fetch determina cuándo se carga una relación desde la base de datos.

  • FetchType.LAZY: al consultar el departamento, JPA no necesita cargar inmediatamente todos sus empleados. La colección se solicita cuando el código intenta acceder a ella.
  • FetchType.EAGER: la relación debe quedar disponible junto con la entidad principal, aunque el código todavía no la haya utilizado, como en la composición Ciudadano–Licencia de arriba, donde conviene cargar las licencias de una vez.
DepartmentEntity department = departmentRepository.findById(id).get();
// Con LAZY, acceder a la colección puede provocar su consulta en este momento.
List<EmployeeEntity> employees = department.getEmployees();

LAZY evita cargar grafos completos de entidades cuando una operación solo necesita los datos del departamento. El acceso a la colección debe realizarse mientras el contexto de persistencia correspondiente siga disponible.

MascotaEntity

id es generado; nombre es obligatorio; microchip es único. @ManyToOne con @JoinColumn(name="refugio_id") crea la llave foránea. La lista de adopciones usa mappedBy="mascota" y no crea columna en MASCOTA_ENTITY.

RefugioEntity

Tiene nombre obligatorio, dirección, teléfono y capacidad. Sus listas de mascotas y adopciones son relaciones @OneToMany inversas; las llaves foráneas viven en las otras tablas.

AdopcionEntity

Incluye fecha obligatoria, nombre y teléfono del adoptante. Dos relaciones @ManyToOne crean mascota_id y refugio_id en ADOPCION_ENTITY.

Los tipos Java se traducen a tipos SQL: String a VARCHAR, Integer a INTEGER, Long a BIGINT y LocalDate a DATE. JPA requiere un constructor vacío para las entidades.

Explorador Mascota–Refugio–Adopción

Selecciona una entidad y observa cómo sus atributos y relaciones aparecen en las tablas.

Clase JavaMascotaEntity@Entity
JPA→
Tabla SQLMASCOTA_ENTITYLlaves y columnas
Mapeo de atributos
Relaciones
@ManyToOneREFUGIO_ENTITY

Muchas mascotas pertenecen a un refugio

REFUGIO_ID
@OneToManyADOPCION_ENTITY

Una mascota tiene un historial de adopciones

MASCOTA_ID vive en ADOPCION_ENTITY
Datos de ejemplo en MASCOTA_ENTITY
IDNOMBREEDADMICROCHIPREFUGIO_ID
1Luna32023-00011
2Max52023-00021
3Bella22023-00032

Una fecha se declara con los tipos del paquete java.time, que JPA sabe guardar sin anotaciones adicionales:

Tipo Java Guarda Tipo SQL
LocalDate solo la fecha (1988-05-14) DATE
LocalDateTime fecha y hora, sin zona horaria TIMESTAMP
private LocalDate fechaNacimiento;

Para una fecha de nacimiento o de lanzamiento, que no tiene hora, el tipo es LocalDate. Es mejor que guardarla como String, porque la base de datos puede ordenar y comparar fechas de verdad, y un LocalDate no puede representar una fecha que no existe (el 30 de febrero).

Existe también java.util.Date, pero no se usa en el curso. Mezcla fecha y hora en un solo tipo, necesita @Temporal para decir cuál de las dos guardar, y depende de la zona horaria de la máquina: en una prueba con el servidor en UTC-5, la fecha 1988-05-14 regresó como 1988-05-13. LocalDate no tiene zona horaria, así que ese error no puede ocurrir.

En el JSON de la API, un LocalDate viaja como texto yyyy-MM-dd ("1988-05-14"), sin configurar nada. Un formato incorrecto o una fecha imposible produce un 400.

@ElementCollection persiste colecciones de tipos básicos o embebibles sin identidad independiente. Crea una tabla separada con una relación uno a muchos hacia la entidad propietaria.

@ElementCollection @OneToMany
Valores sin identificador propio Entidades con identificador
Solo tienen sentido dentro del padre Pueden existir y consultarse independientemente

Es apropiado para contactos, etiquetas, categorías, historiales numéricos y otros valores múltiples simples. No conviene para objetos con identidad, metadatos propios, grandes volúmenes o consultas independientes frecuentes.

@ElementCollection
@CollectionTable(
name = "producto_tags",
joinColumns = @JoinColumn(name = "producto_id")
)
@Column(name = "tag")
private Set<String> tags;

JPA crea PRODUCTO y PRODUCTO_TAGS; esta última contiene producto_id y tag.

@ElementCollection
@CollectionTable(name = "calificaciones")
@Column(name = "nota")
private List<Double> notas;

También se pueden declarar varias colecciones en una entidad, por ejemplo correos, teléfonos y habilidades de un usuario; cada colección tiene su tabla.

Las anotaciones relacionadas son:

  • @ElementCollection: declara la colección.
  • @CollectionTable: personaliza tabla y llave foránea.
  • @Column: personaliza la columna del valor.
  • @OrderColumn: conserva el orden de una lista.
  • @Enumerated: define cómo persistir enums.

Cuando se crea un objeto de una clase @Entity, ese objeto todavía no tiene ninguna relación con la base de datos: está en estado Transient. Al guardarlo (o al obtenerlo con una consulta) pasa a estado Managed: JPA lo vigila mientras dure la transacción. Cuando la transacción termina, o el objeto se separa explícitamente, queda Detached: sigue existiendo en memoria, pero JPA ya no lo sincroniza con la base de datos.

El conjunto de los objetos en estado Managed de una transacción se llama contexto de persistencia. Mientras un objeto está ahí:

  • JPA no repite consultas: si se pide el mismo objeto dos veces, la segunda usa la instancia que ya tiene en memoria en vez de volver a consultar la base de datos.
  • Los cambios se sincronizan solos: modificar un atributo de un objeto Managed no requiere volver a llamar save(); JPA detecta el cambio (dirty checking) y genera el UPDATE al terminar la transacción.

Ciclo de vida de una entidad

De Transient a Managed a Detached, con el mismo objeto EmployeeEntity.

Entrada al servicioEmployeeEntity nuevo, con name y salary asignados
Estado previo de la BDSin fila para este empleado todavía
Resultado esperadoLa fila se actualiza sola con el nuevo salario, sin volver a llamar save()
AplicaciónPaso 1 de 5
Se crea el objeto

new EmployeeEntity() todavía no tiene relación con la base de datos: está en estado Transient.

EmployeeEntity e = new EmployeeEntity(); e.setName("Ana"); e.setSalary(5000.0);

Este mismo mecanismo de sincronización automática es, en el fondo, la razón por la que en una asociación bidireccional JPA solo guarda lo que cambió en el lado propietario, como se vio en “Mapeo de las asociaciones”.

Con el contexto de persistencia se entiende qué ocurre al agregar una instancia a una asociación. Un artista graba un álbum: en la relación @ManyToMany entre ArtistaEntity y AlbumEntity, el álbum se agrega a la colección del artista.

ArtistaEntity artista = artistaRepository.findById(artistaId).get();
AlbumEntity album = albumRepository.findById(albumId).get();
artista.getAlbumes().add(album);

Dentro de una transacción esto basta: artista está Managed, JPA detecta que albumes cambió (dirty checking) y, al terminar la transacción, inserta la fila en artista_album. No hace falta save(). Si el artista está Detached o es nuevo, sí hace falta artistaRepository.save(artista).

Se agrega por ArtistaEntity porque es el dueño de la asociación: el lado que declara @JoinTable y que JPA consulta para decidir qué filas tiene la tabla intermedia. AlbumEntity.artistas es el lado inverso, marcado con mappedBy = "albumes": nombra el atributo del dueño y le deja a este la administración de la relación. JPA llena esa colección al leer, pero ignora lo que se le agregue al guardar. album.getArtistas().add(artista) seguido de albumRepository.save(album) no inserta nada y no avisa.

¿Y agregar el artista al álbum? Solo hace falta si el código va a leer album.getArtistas() en esa misma transacción: tras agregar por el dueño, el álbum todavía no refleja al artista, y en una transacción nueva sí. En ese caso se actualizan los dos lados:

artista.getAlbumes().add(album); // guarda la relación
album.getArtistas().add(artista); // solo mantiene la memoria consistente

Agregar por los dos lados no duplica la fila: se inserta una sola. Un método en ArtistaEntity que haga las dos asignaciones evita olvidar una.

Complete la relación compañía–departamentos:

// CompanyEntity
(mappedBy = "company")
private List<DepartmentEntity> departments;

// DepartmentEntity

private CompanyEntity company;

Complete una colección de etiquetas:


@CollectionTable(name = "producto_tags", joinColumns = @JoinColumn(name = "producto_id"))
(name = "tag")
private Set<String> tags;

Complete la relación uno a uno entre ciudad y alcalde:

// CityEntity

private CitizenEntity mayor;

// CitizenEntity
(mappedBy = "mayor")
private CityEntity manage;

Complete la composición entre ciudadano y sus licencias de conducción:

// CitizenEntity
@OneToMany(
  mappedBy = "citizen",
  cascade = ,
   = true
)
private List<DriverLicenseEntity> driverLicenses;

Complete la relación muchos a muchos entre artistas y álbumes:

// ArtistaEntity

@JoinTable(name = "artista_album", joinColumns = @JoinColumn(name = "artista_id"), inverseJoinColumns = @JoinColumn(name = "album_id"))
private List<AlbumEntity> albumes;

// AlbumEntity
(mappedBy = "albumes")
private List<ArtistaEntity> artistas;
1. ¿En qué lado se almacena la llave foránea de una relación Department–Employee?
2. ¿Qué significa mappedBy?
3. ¿Qué indica FetchType.LAZY en la colección employees?
4. ¿Cuándo corresponde @ElementCollection?
5. ¿Qué anotación permite personalizar la tabla de una colección?
6. ¿Por qué una relación @ManyToMany necesita una tabla intermedia?
7. En la relación @OneToOne entre CityEntity y CitizenEntity, ¿quién contiene la llave foránea?
8. ¿Por qué la relación Ciudad–Carro no usa cascade, pero Ciudadano–Licencia sí?
9. Se ejecuta repository.save(e) y, en la misma transacción, e.setSalary(...). ¿Qué hace falta para que el nuevo salario llegue a la base de datos?
10. Si solo se ejecuta department.getEmployees().add(employee), sin tocar employee.setDepartment(...), ¿qué pasa al guardar employee?
11. En la relación @ManyToMany entre ArtistaEntity (dueño, con @JoinTable) y AlbumEntity (inverso, con mappedBy), ¿qué línea guarda la fila en artista_album?