department.getEmployees().add(employee)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.
Mapeo de las asociaciones
Sección titulada «Mapeo de las asociaciones»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.
La decisión de diseño
Sección titulada «La decisión de diseño»Mapear una asociación del modelo de dominio a JPA implica tomar tres decisiones:
- Cardinalidad: ¿uno a uno, uno a muchos, o muchos a muchos?
- ¿Bidireccional o unidireccional?: ¿el código necesita navegar la relación desde las dos entidades, o solo desde una?
- ¿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.
Cardinalidad
Sección titulada «Cardinalidad»JPA ofrece una anotación distinta según cuántas instancias participan de cada lado de la asociación.
@OneToOne
Sección titulada «@OneToOne»@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:
@Entitypublic class CityEntity extends BaseEntity { private String name;
@OneToOne private CitizenEntity mayor;}@Entitypublic 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
}
@OneToMany / @ManyToOne
Sección titulada «@OneToMany / @ManyToOne»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@ManyToOneprivate 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
}
@ManyToMany
Sección titulada «@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. 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.
@Entitypublic 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<>();}@Entitypublic 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@ManyToOneprivate DepartmentEntity department;
// DepartmentEntity no declara ningún atributo de esta relaciónDEPARTMENT_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.
employee.department es null; no hay fila todavía para employeeDEPARTMENT_ID queda en NULL: la relación no se guardó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);Asociación simple
Sección titulada «Asociación simple»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.
@Entitypublic class CityEntity extends BaseEntity { private String name;
@OneToMany(mappedBy = "city") private List<CarEntity> cars = new ArrayList<>();}@Entitypublic class CarEntity extends BaseEntity { private String plate; private Integer model;
@ManyToOne private CityEntity city;}Composición
Sección titulada «Composición»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:
@Entitypublic class CitizenEntity extends BaseEntity { private String name;
@OneToMany( mappedBy = "citizen", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true ) private List<DriverLicenseEntity> driverLicenses = new ArrayList<>();}@Entitypublic 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.
cascade y orphanRemoval
Sección titulada «cascade 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.ALLpropaga elsave()y el borrado del ciudadano a sus licencias: no hace falta guardarlas ni borrarlas una por una.orphanRemoval = trueborra 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. |
¿Cuándo se carga la relación?
Sección titulada «¿Cuándo se carga la relación?»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.
Del código Java a las tablas
Sección titulada «Del código Java a las tablas»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.
@EntityLlaves y columnasMapeo de atributos
Relaciones
Muchas mascotas pertenecen a un refugio
REFUGIO_IDUna mascota tiene un historial de adopciones
MASCOTA_ID vive en ADOPCION_ENTITYDatos de ejemplo en MASCOTA_ENTITY
| ID | NOMBRE | EDAD | MICROCHIP | REFUGIO_ID |
|---|---|---|---|---|
| 1 | Luna | 3 | 2023-0001 | 1 |
| 2 | Max | 5 | 2023-0002 | 1 |
| 3 | Bella | 2 | 2023-0003 | 2 |
Fechas en las entidades
Sección titulada «Fechas en las entidades»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.
Atributos colección de datos básicos
Sección titulada «Atributos colección de datos básicos»@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.
Etiquetas de un producto
Sección titulada «Etiquetas de un producto»@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.
Calificaciones de un estudiante
Sección titulada «Calificaciones de un estudiante»@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.
Contexto de persistencia y ciclo de vida
Sección titulada «Contexto de persistencia y ciclo de vida»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 elUPDATEal terminar la transacción.
Ciclo de vida de una entidad
De Transient a Managed a Detached, con el mismo objeto EmployeeEntity.
EmployeeEntity nuevo, con name y salary asignadosSin fila para este empleado todavíaLa fila se actualiza sola con el nuevo salario, sin volver a llamar save()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”.
Agregar una instancia a una asociación
Sección titulada «Agregar una instancia a una asociación»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ónalbum.getArtistas().add(artista); // solo mantiene la memoria consistenteAgregar 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.
Autoevaluación
Sección titulada «Autoevaluación»Práctica: relaciones
Sección titulada «Práctica: relaciones»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;