Módulo 15: Relaciones y herencia
Objetivos del módulo
- Entender las asociaciones entre entidades: multiplicidad, dirección (unidireccional y bidireccional) y lado propietario (
mappedBy) - Mapear
@ManyToOney@OneToManycon las tablas de EMPRESA (Empleado–Departamento, Pedido–LineaPedido) - Mapear
@OneToOney@ManyToMany, y convertir una N:M con atributos en una entidad intermedia (Asignacioncon@EmbeddedIdy@MapsId) - Modelar autorrelaciones (
Empleado.jefe,Categoria.padre) - Escribir métodos de sincronización que mantengan coherentes los dos lados de una relación
- Elegir entre carga LAZY y EAGER y entender (y evitar) la
LazyInitializationException - Usar cascadas y
orphanRemovalcorrectamente - Trabajar con colecciones
ListySety ordenarlas con@OrderBy - Mapear herencia con
SINGLE_TABLE,JOINED,TABLE_PER_CLASSy@MappedSuperclass, comparando el SQL que genera cada estrategia - Implementar
equalsyhashCodede forma segura en entidades
1. Asociaciones entre entidades
En el módulo 14 mapeamos las claves ajenas como simples números (Integer deptId). Funciona, pero no es orientado a objetos: para saber el nombre del departamento de un empleado había que hacer otro find a mano. Ahora vamos a sustituir esos números por referencias a objetos, de modo que puedas escribir empleado.getDepartamento().getNombre() y dejar que Hibernate genere el SQL.
📘 Concepto: Una asociación es una relación entre dos entidades que en la base de datos se materializa mediante una clave ajena (o una tabla de unión). Se caracteriza por su multiplicidad (cuántos objetos hay a cada lado) y su dirección (desde qué lado se puede navegar).
| Multiplicidad | Ejemplo en EMPRESA | Anotaciones | Cómo se guarda |
|---|---|---|---|
| Muchos a uno (N:1) | Muchos empleados → un departamento | @ManyToOne | Clave ajena en la tabla del lado “muchos” (empleados.dept_id) |
| Uno a muchos (1:N) | Un departamento → muchos empleados | @OneToMany(mappedBy = ...) | La misma clave ajena, vista desde el otro lado |
| Uno a uno (1:1) | Un empleado → una credencial | @OneToOne | Clave ajena única o clave primaria compartida |
| Muchos a muchos (N:M) | Empleados ↔ habilidades | @ManyToMany + @JoinTable | Tabla de unión con dos claves ajenas |
| N:M con atributos | Empleados ↔ proyectos (con horas y función) | Entidad intermedia + 2 @ManyToOne | Tabla asignaciones con su propia entidad |
⚠️ Importante: EMPRESA no tiene ninguna relación 1:1 ni ninguna N:M “pura” (sin atributos). Para practicarlas usaremos tres tablas de laboratorio (
credenciales,habilidadesyempleado_habilidad) que crearás con un script en la sección 7 y borrarás al terminar el módulo. Para la herencia usaremos tablas que crea y borra Hibernate (create-drop). EMPRESA no se modifica.
2. Dirección de una asociación y lado propietario
Una asociación puede ser:
- Unidireccional: solo un lado conoce al otro.
Empleadotiene un atributodepartamento, peroDepartamentono tiene lista de empleados. - Bidireccional: ambos lados se conocen.
Empleado.departamentoyDepartamento.empleados.
En una bidireccional hay dos atributos Java pero una sola clave ajena. ¿Cuál de los dos manda cuando no coinciden? El lado propietario.
Figura 1. Lado propietario y lado inverso de una relación bidireccional.
📘 Concepto: El lado propietario (owning side) es el que contiene la información de mapeo de la clave ajena (
@JoinColumn). Hibernate solo mira ese lado para escribir en la base de datos. El lado inverso se marca conmappedBy = "nombreDelAtributoEnElOtroLado"y solo sirve para navegar: sus cambios se ignoran al guardar.
Reglas prácticas:
| Relación | Lado propietario | Lado inverso |
|---|---|---|
@ManyToOne / @OneToMany | Siempre el @ManyToOne (la tabla del lado “muchos” tiene la FK) | El @OneToMany(mappedBy = ...) |
@OneToOne | El que tiene la clave ajena (@JoinColumn o @MapsId) | El que lleva mappedBy |
@ManyToMany | El que declara @JoinTable | El que lleva mappedBy |
💡 Consejo: No hagas bidireccional una relación “por si acaso”. Añade el lado inverso solo si de verdad vas a navegar en esa dirección. Cada lado inverso es una colección más que mantener sincronizada y una posible fuente de consultas inesperadas.
3. @ManyToOne y @OneToMany: empleados y departamentos
Figura 2. Relación N:1 / 1:N entre objetos y entre tablas.
💡 Consejo: Como en el módulo 14, los ejemplos omiten las líneas
packageeimport(salvo enEmpleado): IntelliJ las añade con Alt+Intro. El proyecto es el del módulo anterior, con las mismasJpaUtil,persistence.xmlypom.xml.
3.1 El lado propietario: Empleado
package es.curso.empresa.modelo;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
@Entity
@Table(name = "empleados")
public class Empleado {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "emp_id")
private Integer id;
// ... nombre, apellidos, email, telefono, fechaAlta, oficio, salario, comision (como en el módulo 14)
// Lado PROPIETARIO de la relación con Departamento: aquí está la columna dept_id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "dept_id")
private Departamento departamento;
// Autorrelación: muchos empleados tienen un mismo jefe
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "jefe_id")
private Empleado jefe;
@OneToMany(mappedBy = "jefe")
@OrderBy("id")
private List<Empleado> subordinados = new ArrayList<>();
// N:M con atributos a través de la entidad Asignacion (sección 9)
@OneToMany(mappedBy = "empleado", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Asignacion> asignaciones = new HashSet<>();
// N:M simple (tabla de unión empleado_habilidad, sección 8)
@ManyToMany
@JoinTable(name = "empleado_habilidad",
joinColumns = @JoinColumn(name = "emp_id"),
inverseJoinColumns = @JoinColumn(name = "habilidad_id"))
private Set<Habilidad> habilidades = new HashSet<>();
protected Empleado() { }
// constructor, getters, setters...
}
| Elemento | Significado |
|---|---|
@ManyToOne | Muchos empleados apuntan a un departamento |
fetch = FetchType.LAZY | No cargar el departamento hasta que se use (sección 5) |
@JoinColumn(name = "dept_id") | Nombre de la columna de clave ajena en empleados. Sin ella, Hibernate buscaría departamento_dept_id |
3.2 El lado inverso: Departamento
@Entity
@Table(name = "departamentos")
public class Departamento {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "dept_id")
private Integer id;
@Column(nullable = false, length = 40, unique = true)
private String nombre;
@Column(length = 40)
private String localidad;
@Column(precision = 12, scale = 2)
private BigDecimal presupuesto;
// Lado INVERSO: la clave ajena dept_id la gestiona Empleado.departamento
@OneToMany(mappedBy = "departamento")
@OrderBy("apellidos")
private List<Empleado> empleados = new ArrayList<>();
@OneToMany(mappedBy = "departamento")
private List<Proyecto> proyectos = new ArrayList<>();
protected Departamento() { }
public Departamento(String nombre, String localidad) {
this.nombre = nombre;
this.localidad = localidad;
}
// --- Métodos de sincronización: mantienen coherentes AMBOS lados ---
public void addEmpleado(Empleado e) {
empleados.add(e);
e.setDepartamento(this);
}
public void removeEmpleado(Empleado e) {
empleados.remove(e);
e.setDepartamento(null);
}
// getters...
}
⚠️ Importante: Inicializa siempre las colecciones (
= new ArrayList<>()). Un objeto nuevo creado connewno pasa por Hibernate, y si la colección fueranull,addEmpleadolanzaría unNullPointerException.
3.3 Navegar de un empleado a su departamento (y a sus jefes)
public class Ejemplo01ManyToOne {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
System.out.println("--- find(Empleado, 121) ---");
Empleado raquel = em.find(Empleado.class, 121);
System.out.println(raquel.getNombreCompleto());
// El departamento es LAZY: de momento es un proxy sin datos
var dep = raquel.getDepartamento();
System.out.println("Clase del departamento: " + dep.getClass().getSimpleName());
System.out.println("Id (no necesita SQL): " + dep.getId());
System.out.println("--- getNombre() del departamento ---");
System.out.println("Departamento: " + dep.getNombre());
System.out.println("--- Subimos por la cadena de jefes ---");
Empleado actual = raquel;
while (actual.getJefe() != null) {
actual = actual.getJefe();
System.out.println("Jefe: " + actual.getNombreCompleto());
}
}
JpaUtil.cerrar();
}
}
Salida real (las tres consultas de jefes son idénticas; se muestra solo la primera):
--- find(Empleado, 121) ---
Hibernate:
select
e1_0.emp_id,
e1_0.apellidos,
e1_0.comision,
e1_0.dept_id,
e1_0.email,
e1_0.fecha_alta,
e1_0.jefe_id,
e1_0.nombre,
e1_0.oficio,
e1_0.salario,
e1_0.telefono
from
empleados e1_0
where
e1_0.emp_id=?
Raquel Molina Prieto
Clase del departamento: Departamento$HibernateProxy
Id (no necesita SQL): 30
--- getNombre() del departamento ---
Hibernate:
select
d1_0.dept_id,
d1_0.localidad,
d1_0.nombre,
d1_0.presupuesto
from
departamentos d1_0
where
d1_0.dept_id=?
Departamento: INFORMÁTICA
--- Subimos por la cadena de jefes ---
Hibernate:
select
e1_0.emp_id,
...
from
empleados e1_0
where
e1_0.emp_id=?
Jefe: Pablo Moreno Herrera
Hibernate:
select ... (igual)
Jefe: Marta Sánchez Gil
Hibernate:
select ... (igual)
Jefe: Ana García López
Fíjate en tres cosas:
- El primer
SELECTsolo leeempleados: el departamento no se carga todavía. Hibernate guarda endepartamentoun proxy, un objeto de una subclase generada (Departamento$HibernateProxy) que solo conoce el id. - Pedir el id al proxy no genera SQL (ya lo tiene: es el valor de
dept_id). Pedir el nombre sí: en ese momento el proxy se inicializa con unSELECT. - Cada salto a un jefe es una consulta. Para una cadena corta no importa; en un listado de cien empleados sería un problema serio (el N+1 del módulo 16).
3.4 Recorrer la colección de un departamento
public class Ejemplo02OneToMany {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
System.out.println("--- find(Departamento, 30) ---");
Departamento informatica = em.find(Departamento.class, 30);
var lista = informatica.getEmpleados();
System.out.println("Clase de la colección: " + lista.getClass().getSimpleName());
System.out.println("--- Recorremos la colección ---");
for (Empleado e : lista) {
System.out.println(" " + e.getApellidos() + ", " + e.getNombre());
}
System.out.println("Total: " + lista.size() + " empleados");
}
JpaUtil.cerrar();
}
}
Salida real:
--- find(Departamento, 30) ---
Hibernate: select ... from departamentos d1_0 where d1_0.dept_id=?
Clase de la colección: PersistentBag
--- Recorremos la colección ---
Hibernate:
select
e1_0.dept_id,
e1_0.emp_id,
...
e1_0.telefono
from
empleados e1_0
where
e1_0.dept_id=?
order by
e1_0.apellidos
Castro Méndez, Iván
Molina Prieto, Raquel
Moreno Herrera, Pablo
Ortiz Delgado, Sara
Rubio Marín, Hugo
Sánchez Gil, Marta
Total: 6 empleados
La lista que devuelve getEmpleados() no es tu ArrayList: Hibernate la sustituye por una colección propia (PersistentBag) que se carga sola la primera vez que la usas. Y el @OrderBy("apellidos") se traduce en un order by e1_0.apellidos.
4. Mantener sincronizados los dos lados
💻 Ejemplo: El error más típico con relaciones bidireccionales es modificar solo el lado inverso. Este programa crea un departamento y dos empleados: a uno lo añade solo a la lista (mal) y al otro con el método de sincronización (bien).
public class Ejemplo03LadoPropietario {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
em.getTransaction().begin();
Departamento calidad = new Departamento("CALIDAD", "Toledo");
em.persist(calidad);
// ERROR TÍPICO: solo tocamos el lado inverso (la colección)
Empleado e1 = new Empleado("Olga", "Prieto Sanz", "olga.prieto@empresa.es",
"AUDITOR", new BigDecimal("2400"));
calidad.getEmpleados().add(e1); // ¡no se rellena dept_id!
em.persist(e1);
// CORRECTO: método de sincronización (fija también e2.departamento)
Empleado e2 = new Empleado("Raúl", "Vega Ramos", "raul.vega@empresa.es",
"AUDITOR", new BigDecimal("2400"));
calidad.addEmpleado(e2);
em.persist(e2);
em.getTransaction().commit();
em.clear(); // olvidamos los objetos para releer de la BD
Departamento releido = em.find(Departamento.class, calidad.getId());
System.out.println("Empleados de CALIDAD según la BD: " + releido.getEmpleados());
// Limpieza: dejamos EMPRESA como estaba
em.clear();
em.getTransaction().begin();
em.remove(em.find(Empleado.class, e1.getId()));
em.remove(em.find(Empleado.class, e2.getId()));
em.remove(em.find(Departamento.class, calidad.getId()));
System.out.println("--- limpieza ---");
em.getTransaction().commit();
}
JpaUtil.cerrar();
}
}
Salida real (sin las consultas de la limpieza):
Hibernate:
insert
into
departamentos
(localidad, nombre, presupuesto)
values
(?, ?, ?)
Hibernate:
insert
into
empleados
(apellidos, comision, dept_id, email, fecha_alta, jefe_id, nombre, oficio, salario, telefono)
values
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: insert into empleados (apellidos, comision, dept_id, email, fecha_alta, jefe_id, nombre, oficio, salario, telefono) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate:
select ... from departamentos d1_0 where d1_0.dept_id=?
Hibernate:
select ... from empleados e1_0 where e1_0.dept_id=? order by e1_0.apellidos
Empleados de CALIDAD según la BD: [Empleado[164, Raúl Vega Ramos]]
--- limpieza ---
Hibernate:
delete from empleado_habilidad where emp_id=?
Hibernate: delete from empleado_habilidad where emp_id=?
Hibernate:
delete from empleados where emp_id=?
Hibernate: delete from empleados where emp_id=?
Hibernate:
delete from departamentos where dept_id=?
Los dos INSERT de empleados son idénticos, pero en el de Olga el parámetro dept_id vale NULL, porque su atributo departamento (el lado propietario) era null. Al releer de la base de datos, solo Raúl pertenece a CALIDAD. Observa también que, al borrar un empleado, Hibernate borra antes sus filas de la tabla de unión empleado_habilidad (sección 8): de esa tabla se encarga él.
💡 Consejo: Escribe métodos
addXxx/removeXxxen el lado que tenga la colección y úsalos siempre. Así el modelo de objetos en memoria es coherente con lo que se guardará y no te llevarás sorpresas dentro de la misma transacción (donde todavía no has releído de la base de datos).
5. Carga perezosa (LAZY) y ansiosa (EAGER)
📘 Concepto: La estrategia de carga (
fetch) indica cuándo se leen los objetos asociados: EAGER (ansiosa) los carga siempre junto con la entidad; LAZY (perezosa) los carga solo cuando se accede a ellos, mediante un proxy o una colección persistente.
| Anotación | Valor por defecto | Recomendación |
|---|---|---|
@ManyToOne | EAGER | Cambia a LAZY explícitamente |
@OneToOne | EAGER | Cambia a LAZY explícitamente |
@OneToMany | LAZY | Déjalo así |
@ManyToMany | LAZY | Déjalo así |
⚠️ Importante: Los valores por defecto de JPA para las relaciones “a uno” son una herencia histórica poco afortunada. La regla profesional es: todas las asociaciones LAZY, y cuando una pantalla necesite los datos asociados, pídelos explícitamente en la consulta (
JOIN FETCHo@EntityGraph, módulo 16).
5.1 EAGER en acción
En Producto dejamos a propósito @ManyToOne sin fetch (es decir, EAGER):
@ManyToOne // EAGER por defecto
@JoinColumn(name = "categoria_id", nullable = false)
private Categoria categoria;
public class Ejemplo05Eager {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
// Producto.categoria es @ManyToOne sin fetch => EAGER
System.out.println("--- find(Producto, 7): EAGER usa un JOIN ---");
Producto p = em.find(Producto.class, 7);
System.out.println(p.getNombre() + " -> " + p.getCategoria().getNombre());
// Con una consulta JPQL, EAGER provoca una consulta EXTRA por cada categoría distinta
System.out.println("--- JPQL: productos de menos de 100 euros ---");
var lista = em.createQuery(
"select p from Producto p where p.precio < 100", Producto.class)
.getResultList();
lista.forEach(x -> System.out.println(" " + x.getNombre() + " (" + x.getCategoria().getNombre() + ")"));
}
JpaUtil.cerrar();
}
}
Salida real:
--- find(Producto, 7): EAGER usa un JOIN ---
Hibernate:
select
p1_0.producto_id,
p1_0.categoria_id,
...
p1_0.stock
from
productos p1_0
join
categorias c1_0
on c1_0.categoria_id=p1_0.categoria_id
where
p1_0.producto_id=?
Teclado mecánico -> Ratones y teclados
--- JPQL: productos de menos de 100 euros ---
Hibernate:
select
p1_0.producto_id,
p1_0.categoria_id,
...
p1_0.stock
from
productos p1_0
where
p1_0.precio<100
Hibernate:
select
c1_0.categoria_id,
c1_0.nombre,
c1_0.categoria_padre_id
from
categorias c1_0
where
c1_0.categoria_id=?
Hibernate:
select ... (igual, otra categoría)
Hibernate:
select ... (igual, otra categoría)
Ratón inalámbrico (Ratones y teclados)
Teclado mecánico (Ratones y teclados)
Pack teclado y ratón (Ratones y teclados)
Auriculares Bluetooth (Audio)
Altavoz Inteligente (Audio)
Webcam HD (Periféricos)
Disco SSD 1TB (Informática)
- Con
find, EAGER se resuelve con unJOIN: aceptable. - Con JPQL, Hibernate ejecuta la consulta tal cual (sin
JOIN) y luego, antes de devolver la lista, lanza una consulta por cada categoría que no tenga ya en el contexto: tres consultas extra (Audio, Periféricos e Informática; “Ratones y teclados” ya estaba cargada por elfindanterior). Y lo hace aunque no vayas a usar las categorías. Ese es el gran problema de EAGER: no se puede desactivar en una consulta concreta.
5.2 LazyInitializationException
La carga perezosa tiene una condición: el proxy necesita un EntityManager abierto para lanzar su SELECT. Si intentas usarlo después de cerrarlo, obtienes la excepción más famosa de Hibernate.
Figura 3. Carga perezosa con proxies y causa de la LazyInitializationException.
public class Ejemplo04LazyInit {
static Empleado buscar(int id) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
return em.find(Empleado.class, id);
} // el EntityManager se cierra: el empleado queda DESCONECTADO
}
public static void main(String[] args) {
Empleado e = buscar(122);
System.out.println("Empleado: " + e.getNombreCompleto());
System.out.println("¿Departamento inicializado? " + Hibernate.isInitialized(e.getDepartamento()));
try {
System.out.println(e.getDepartamento().getNombre());
} catch (RuntimeException ex) {
System.out.println(ex.getClass().getName());
System.out.println(ex.getMessage());
}
// Solución 1: cargar lo necesario con JOIN FETCH (módulo 16)
try (EntityManager em = JpaUtil.crearEntityManager()) {
Empleado conDep = em.createQuery("""
select e from Empleado e
join fetch e.departamento
where e.id = :id""", Empleado.class)
.setParameter("id", 122)
.getSingleResult();
em.close();
System.out.println("JOIN FETCH -> " + conDep.getDepartamento().getNombre());
}
// Solución 2: inicializar explícitamente antes de cerrar
Departamento dep;
try (EntityManager em = JpaUtil.crearEntityManager()) {
dep = em.find(Departamento.class, 40);
Hibernate.initialize(dep.getEmpleados());
}
System.out.println("Hibernate.initialize -> " + dep.getEmpleados().size() + " empleados en "
+ dep.getNombre());
JpaUtil.cerrar();
}
}
Salida real (las columnas de los select se han abreviado):
Hibernate:
select
e1_0.emp_id,
...
from
empleados e1_0
where
e1_0.emp_id=?
Empleado: Iván Castro Méndez
¿Departamento inicializado? false
org.hibernate.LazyInitializationException
Could not initialize proxy [es.curso.empresa.modelo.Departamento#30] - no session
Hibernate:
select
e1_0.emp_id,
e1_0.apellidos,
...
e1_0.telefono
from
empleados e1_0
join
departamentos d1_0
on d1_0.dept_id=e1_0.dept_id
where
e1_0.emp_id=?
JOIN FETCH -> INFORMÁTICA
Hibernate:
select ... from departamentos d1_0 where d1_0.dept_id=?
Hibernate:
select ... from empleados e1_0 where e1_0.dept_id=? order by e1_0.apellidos
Hibernate.initialize -> 3 empleados en CONTABILIDAD
El mensaje lo dice todo: no session. Con JOIN FETCH, la misma consulta trae empleado y departamento a la vez, y después de cerrar el EntityManager el dato ya está en memoria.
⚠️ Importante: La “solución” de poner
EAGERpara que no salte la excepción es la peor de todas: cargarás el departamento en todas las consultas de empleados, lo necesites o no. Tampoco uses la propiedadhibernate.enable_lazy_load_no_trans: abre una conexión nueva por cada proxy y esconde un problema de diseño.
6. Cascadas y orphanRemoval
📘 Concepto: Una cascada propaga una operación del
EntityManager(persist,merge,remove…) desde una entidad a las entidades asociadas. Se declara en la relación concascade = ....
CascadeType | Propaga | Ejemplo de uso |
|---|---|---|
PERSIST | persist() | Guardar un pedido nuevo guarda sus líneas nuevas |
MERGE | merge() | Fusionar un pedido desconectado fusiona sus líneas |
REMOVE | remove() | Borrar un pedido borra sus líneas |
REFRESH | refresh() | Releer el pedido relee sus líneas |
DETACH | detach() | Desconectar el pedido desconecta sus líneas |
ALL | Todas las anteriores | Composición fuerte: la parte no existe sin el todo |
📘 Concepto:
orphanRemoval = trueborra de la base de datos una entidad hija cuando se quita de la colección del padre (se queda “huérfana”).CascadeType.REMOVEactúa cuando se borra el padre;orphanRemoval, cuando se rompe la relación.
⚠️ Importante: Usa cascadas solo en relaciones de composición (el padre es el dueño del ciclo de vida del hijo):
Pedido→LineaPedido,Empleado→Asignacion. Nunca pongasCascadeType.REMOVEen un@ManyToOne(borrar un empleado borraría su departamento… y con él a todos sus compañeros) ni en un@ManyToMany.
6.1 Pedido y LineaPedido con @MapsId
Recuerda que la clave de lineas_pedido es (pedido_id, num_linea) y que pedido_id es también la clave ajena hacia pedidos. @MapsId le dice a Hibernate que la columna de la relación y la de la clave son la misma, y que debe copiar el id del pedido en la clave de la línea:
@Embeddable
public class LineaPedidoId implements Serializable {
@Column(name = "pedido_id")
private Integer pedidoId; // lo rellena @MapsId a partir del pedido
@Column(name = "num_linea")
private Integer numLinea;
protected LineaPedidoId() { }
public LineaPedidoId(Integer pedidoId, Integer numLinea) {
this.pedidoId = pedidoId;
this.numLinea = numLinea;
}
public Integer getPedidoId() { return pedidoId; }
public Integer getNumLinea() { return numLinea; }
@Override
public boolean equals(Object o) {
return o instanceof LineaPedidoId otro
&& Objects.equals(pedidoId, otro.pedidoId)
&& Objects.equals(numLinea, otro.numLinea);
}
@Override
public int hashCode() { return Objects.hash(pedidoId, numLinea); }
}
@Entity
@Table(name = "lineas_pedido")
public class LineaPedido {
@EmbeddedId
private LineaPedidoId id;
// La parte "pedidoId" de la clave se toma del pedido asociado
@MapsId("pedidoId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pedido_id")
private Pedido pedido;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "producto_id", nullable = false)
private Producto producto;
@Column(nullable = false)
private int cantidad;
@Column(name = "precio_unitario", nullable = false, precision = 8, scale = 2)
private BigDecimal precioUnitario;
@Column(nullable = false, precision = 4, scale = 2)
private BigDecimal descuento;
protected LineaPedido() { }
LineaPedido(Pedido pedido, int numLinea, Producto producto, int cantidad,
BigDecimal precioUnitario, BigDecimal descuento) {
this.id = new LineaPedidoId(null, numLinea); // pedidoId lo pone @MapsId
this.pedido = pedido;
this.producto = producto;
this.cantidad = cantidad;
this.precioUnitario = precioUnitario;
this.descuento = descuento;
}
public BigDecimal getImporte() {
BigDecimal bruto = precioUnitario.multiply(BigDecimal.valueOf(cantidad));
return bruto.multiply(BigDecimal.ONE.subtract(descuento.movePointLeft(2)))
.setScale(2, RoundingMode.HALF_UP);
}
// getters y toString
}
💡 Consejo: En el módulo 14 la clave era un record. Aquí la escribimos como clase normal porque, con
@MapsId, Hibernate necesita rellenarpedidoIddespués de insertar el pedido (cuandoIDENTITYle da el id), y los atributos de un record sonfinal.
Y en Pedido, la colección con cascada y orphanRemoval, más los métodos que sincronizan los dos lados y numeran las líneas:
@Entity
@Table(name = "pedidos")
public class Pedido {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pedido_id")
private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "cliente_id", nullable = false)
private Cliente cliente;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "empleado_id")
private Empleado empleado;
@Column(name = "fecha_pedido", nullable = false)
private LocalDate fechaPedido;
@Column(name = "fecha_envio")
private LocalDate fechaEnvio;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 12)
private EstadoPedido estado;
// Las líneas "pertenecen" al pedido: se guardan y borran con él
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id.numLinea")
private List<LineaPedido> lineas = new ArrayList<>();
protected Pedido() { }
public Pedido(Cliente cliente, Empleado empleado, LocalDate fecha) {
this.cliente = cliente;
this.empleado = empleado;
this.fechaPedido = fecha;
this.estado = EstadoPedido.PENDIENTE;
}
/** Añade una línea numerándola automáticamente y sincroniza los dos lados. */
public LineaPedido addLinea(Producto producto, int cantidad, BigDecimal descuento) {
int num = lineas.size() + 1;
LineaPedido linea = new LineaPedido(this, num, producto, cantidad, producto.getPrecio(), descuento);
lineas.add(linea);
return linea;
}
public void removeLinea(LineaPedido linea) {
lineas.remove(linea);
linea.setPedido(null);
}
public BigDecimal getTotal() {
return lineas.stream().map(LineaPedido::getImporte).reduce(BigDecimal.ZERO, BigDecimal::add);
}
// getters...
}
6.2 Cascadas en acción
public class Ejemplo06Cascada {
public static void main(String[] args) {
Integer pedidoId;
try (EntityManager em = JpaUtil.crearEntityManager()) {
em.getTransaction().begin();
// getReference: un proxy sin SELECT (solo necesitamos la clave ajena)
Cliente cliente = em.getReference(Cliente.class, 1004);
Empleado vendedor = em.getReference(Empleado.class, 111);
Producto raton = em.find(Producto.class, 6);
Producto monitor = em.find(Producto.class, 9);
Pedido pedido = new Pedido(cliente, vendedor, LocalDate.of(2026, 10, 4));
pedido.addLinea(raton, 10, BigDecimal.ZERO);
pedido.addLinea(monitor, 2, new BigDecimal("5"));
System.out.println("--- persist(pedido): CascadeType.PERSIST guarda también las líneas ---");
em.persist(pedido);
em.getTransaction().commit();
pedidoId = pedido.getId();
System.out.println("Pedido " + pedidoId + " con total " + pedido.getTotal());
System.out.println("--- Quitamos la línea 1 de la lista: orphanRemoval la borra ---");
em.getTransaction().begin();
pedido.removeLinea(pedido.getLineas().get(0));
em.getTransaction().commit();
System.out.println("--- remove(pedido): CascadeType.REMOVE borra sus líneas ---");
em.getTransaction().begin();
em.remove(pedido);
em.getTransaction().commit();
}
JpaUtil.cerrar();
}
}
Salida real (sin los dos select de productos del principio):
--- persist(pedido): CascadeType.PERSIST guarda también las líneas ---
Hibernate:
insert
into
pedidos
(cliente_id, empleado_id, estado, fecha_envio, fecha_pedido)
values
(?, ?, ?, ?, ?)
Hibernate:
insert
into
lineas_pedido
(cantidad, descuento, precio_unitario, producto_id, num_linea, pedido_id)
values
(?, ?, ?, ?, ?, ?)
Hibernate: insert into lineas_pedido (cantidad, descuento, precio_unitario, producto_id, num_linea, pedido_id) values (?, ?, ?, ?, ?, ?)
Pedido 5025 con total 463.10
--- Quitamos la línea 1 de la lista: orphanRemoval la borra ---
Hibernate:
delete
from
lineas_pedido
where
num_linea=?
and pedido_id=?
--- remove(pedido): CascadeType.REMOVE borra sus líneas ---
Hibernate: delete from lineas_pedido where num_linea=? and pedido_id=?
Hibernate:
delete
from
pedidos
where
pedido_id=?
- Un solo
persist(pedido)ha generado tresINSERT. El depedidosva primero; en cuanto MySQL devuelve el id (5025),@MapsIdlo copia en la clave de cada línea. getReferenceno ha generado ningúnSELECTde cliente ni de empleado: para elINSERTsolo hacen falta sus ids.- Quitar una línea de la lista basta para borrarla (
orphanRemoval). - Al borrar el pedido, Hibernate borra primero la línea restante y después el pedido, en el orden que exigen las claves ajenas.
7. @OneToOne: empleado y credencial
7.1 Tablas de laboratorio
Ejecuta este script en tu base de datos empresa (lo usaremos en las secciones 7 y 8):
-- Tablas de laboratorio del módulo 15 (no forman parte de EMPRESA)
CREATE TABLE credenciales (
emp_id INT PRIMARY KEY,
usuario VARCHAR(30) NOT NULL,
ultimo_acceso DATETIME(6),
CONSTRAINT uq_cre_usuario UNIQUE (usuario),
CONSTRAINT fk_cre_emp FOREIGN KEY (emp_id) REFERENCES empleados (emp_id)
);
CREATE TABLE habilidades (
habilidad_id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(40) NOT NULL,
CONSTRAINT uq_hab_nombre UNIQUE (nombre)
);
CREATE TABLE empleado_habilidad (
emp_id INT,
habilidad_id INT,
CONSTRAINT pk_emp_hab PRIMARY KEY (emp_id, habilidad_id),
CONSTRAINT fk_eh_emp FOREIGN KEY (emp_id) REFERENCES empleados (emp_id),
CONSTRAINT fk_eh_hab FOREIGN KEY (habilidad_id) REFERENCES habilidades (habilidad_id)
);
INSERT INTO habilidades (nombre) VALUES ('Java'), ('SQL'), ('Docker'), ('Inglés');
INSERT INTO empleado_habilidad VALUES (121, 1), (121, 2), (122, 1), (122, 3), (120, 2), (120, 4);
INSERT INTO credenciales VALUES (120, 'pmoreno', '2026-10-01 08:15:00'), (121, 'rmolina', NULL);
Al terminar el módulo, bórralas:
DROP TABLE empleado_habilidad;
DROP TABLE habilidades;
DROP TABLE credenciales;
7.2 Clave primaria compartida con @MapsId
Cada empleado puede tener como mucho una credencial para entrar en la intranet. La forma más sólida de garantizarlo es que la clave primaria de credenciales sea el emp_id:
Figura 4. Relación 1:1 con clave primaria compartida.
@Entity
@Table(name = "credenciales")
public class Credencial {
@Id
@Column(name = "emp_id")
private Integer id; // mismo valor que el id del empleado
@MapsId // la PK se copia de la relación
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "emp_id")
private Empleado empleado;
@Column(nullable = false, length = 30, unique = true)
private String usuario;
@Column(name = "ultimo_acceso")
private LocalDateTime ultimoAcceso;
protected Credencial() { }
public Credencial(Empleado empleado, String usuario) {
this.empleado = empleado;
this.usuario = usuario;
}
// getters y setters
}
public class Ejemplo09OneToOne {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
Credencial c = em.find(Credencial.class, 120);
System.out.println(c.getUsuario() + " -> " + c.getEmpleado().getNombreCompleto());
System.out.println("--- Credencial nueva para el empleado 122 ---");
em.getTransaction().begin();
Empleado ivan = em.getReference(Empleado.class, 122);
Credencial nueva = new Credencial(ivan, "icastro");
nueva.setUltimoAcceso(LocalDateTime.of(2026, 10, 4, 9, 0));
em.persist(nueva);
em.getTransaction().commit();
System.out.println("Id de la credencial = id del empleado: " + nueva.getId());
em.getTransaction().begin();
em.remove(nueva);
em.getTransaction().commit();
}
JpaUtil.cerrar();
}
}
Salida real:
Hibernate:
select
c1_0.emp_id,
c1_0.ultimo_acceso,
c1_0.usuario
from
credenciales c1_0
where
c1_0.emp_id=?
Hibernate:
select
e1_0.emp_id,
...
from
empleados e1_0
where
e1_0.emp_id=?
pmoreno -> Pablo Moreno Herrera
--- Credencial nueva para el empleado 122 ---
Hibernate:
insert
into
credenciales
(ultimo_acceso, usuario, emp_id)
values
(?, ?, ?)
Id de la credencial = id del empleado: 122
Hibernate:
delete
from
credenciales
where
emp_id=?
No hemos asignado el id de la credencial en ningún momento: @MapsId lo ha tomado del empleado.
⚠️ Importante: Hemos hecho la relación unidireccional a propósito. Si añades en
Empleadoel lado inverso (@OneToOne(mappedBy = "empleado") Credencial credencial), Hibernate no puede saber sin consultar si un empleado tiene o no credencial (no hay ninguna columna enempleadosque lo indique), así que no puede usar un proxy y cargará la credencial con una consulta extra cada vez que cargue un empleado, aunque pongasLAZY. Si necesitas ir de empleado a credencial, usaem.find(Credencial.class, empleado.getId()).
8. @ManyToMany: empleados y habilidades
Figura 5. Las dos formas de mapear una relación N:M.
// En Empleado (lado propietario: define la tabla de unión)
@ManyToMany
@JoinTable(name = "empleado_habilidad",
joinColumns = @JoinColumn(name = "emp_id"),
inverseJoinColumns = @JoinColumn(name = "habilidad_id"))
private Set<Habilidad> habilidades = new HashSet<>();
public void addHabilidad(Habilidad h) {
habilidades.add(h);
h.getEmpleados().add(this);
}
public void removeHabilidad(Habilidad h) {
habilidades.remove(h);
h.getEmpleados().remove(this);
}
@Entity
@Table(name = "habilidades")
public class Habilidad {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "habilidad_id")
private Integer id;
@Column(nullable = false, length = 40, unique = true)
private String nombre;
@ManyToMany(mappedBy = "habilidades") // lado inverso
private Set<Empleado> empleados = new HashSet<>();
protected Habilidad() { }
public Habilidad(String nombre) { this.nombre = nombre; }
// Clave natural: el nombre es único y no cambia
@Override
public boolean equals(Object o) {
return o instanceof Habilidad h && Objects.equals(nombre, h.nombre);
}
@Override
public int hashCode() { return Objects.hashCode(nombre); }
@Override
public String toString() { return nombre; }
// getters...
}
public class Ejemplo10ManyToMany {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
Empleado raquel = em.find(Empleado.class, 121);
System.out.println("--- Habilidades de Raquel ---");
System.out.println(raquel.getHabilidades());
Habilidad docker = em.createQuery(
"select h from Habilidad h where h.nombre = 'Docker'", Habilidad.class)
.getSingleResult();
System.out.println("--- Añadimos Docker (lado propietario: Empleado) ---");
em.getTransaction().begin();
raquel.addHabilidad(docker);
em.getTransaction().commit();
System.out.println("--- Quitamos Docker ---");
em.getTransaction().begin();
raquel.removeHabilidad(docker);
em.getTransaction().commit();
}
JpaUtil.cerrar();
}
}
Salida real (sin el primer select del empleado):
--- Habilidades de Raquel ---
Hibernate:
select
h1_0.emp_id,
h1_1.habilidad_id,
h1_1.nombre
from
empleado_habilidad h1_0
join
habilidades h1_1
on h1_1.habilidad_id=h1_0.habilidad_id
where
h1_0.emp_id=?
[Java, SQL]
Hibernate:
select
h1_0.habilidad_id,
h1_0.nombre
from
habilidades h1_0
where
h1_0.nombre='Docker'
--- Añadimos Docker (lado propietario: Empleado) ---
Hibernate:
select
e1_0.habilidad_id,
e1_1.emp_id,
...
from
empleado_habilidad e1_0
join
empleados e1_1
on e1_1.emp_id=e1_0.emp_id
where
e1_0.habilidad_id=?
Hibernate:
insert
into
empleado_habilidad
(emp_id, habilidad_id)
values
(?, ?)
--- Quitamos Docker ---
Hibernate:
delete
from
empleado_habilidad
where
emp_id=?
and habilidad_id=?
Observa que la carga de la colección hace un JOIN entre la tabla de unión y habilidades, y que añadir o quitar un elemento del Set se traduce en un único INSERT o DELETE en empleado_habilidad. El select intermedio lo provoca h.getEmpleados().add(this) en el método de sincronización: para añadir al lado inverso, Hibernate tiene que cargar esa colección.
⚠️ Importante: Usa
Set(noList) en las colecciones@ManyToMany. Con unaListsin columna de orden (bag), al quitar un elemento Hibernate borra todas las filas de la tabla de unión de ese empleado y vuelve a insertar las que quedan. Con unSetborra solo la fila afectada, como acabas de ver.
💡 Consejo: En cuanto la relación necesite un dato más (desde cuándo tiene la habilidad, con qué nivel…), abandona
@ManyToManyy crea una entidad intermedia. Es lo que hace EMPRESA con las asignaciones a proyectos.
9. N:M con atributos: la entidad Asignacion
La tabla asignaciones relaciona empleados y proyectos, pero además guarda la fecha, las horas y la función. Eso la convierte en una entidad con dos relaciones @ManyToOne cuya clave primaria está formada por las dos claves ajenas.
@Embeddable
public class AsignacionId implements Serializable {
@Column(name = "emp_id")
private Integer empId;
@Column(name = "proyecto_id")
private Integer proyectoId;
protected AsignacionId() { }
public AsignacionId(Integer empId, Integer proyectoId) {
this.empId = empId;
this.proyectoId = proyectoId;
}
// getters, equals y hashCode con los dos atributos
}
/** Entidad intermedia de la relación N:M Empleado-Proyecto, con atributos propios. */
@Entity
@Table(name = "asignaciones")
public class Asignacion {
@EmbeddedId
private AsignacionId id;
@MapsId("empId") // empId de la clave = id del empleado
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "emp_id")
private Empleado empleado;
@MapsId("proyectoId") // proyectoId de la clave = id del proyecto
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "proyecto_id")
private Proyecto proyecto;
@Column(name = "fecha_asignacion", nullable = false)
private LocalDate fechaAsignacion;
private Integer horas;
@Column(length = 30)
private String funcion;
protected Asignacion() { }
public Asignacion(Empleado empleado, Proyecto proyecto, String funcion, LocalDate fecha) {
this.id = new AsignacionId(empleado.getId(), proyecto.getId());
this.empleado = empleado;
this.proyecto = proyecto;
this.funcion = funcion;
this.fechaAsignacion = fecha;
this.horas = 0;
}
// Igualdad por clave: los dos ids identifican la asignación
@Override
public boolean equals(Object o) {
return o instanceof Asignacion otra && id != null && id.equals(otra.id);
}
@Override
public int hashCode() { return Objects.hashCode(id); }
// getters...
}
Proyecto tiene la colección inversa @OneToMany(mappedBy = "proyecto", cascade = CascadeType.ALL, orphanRemoval = true) Set<Asignacion> asignaciones, igual que Empleado.
public class Ejemplo07Asignaciones {
public static void main(String[] args) {
try (EntityManager em = JpaUtil.crearEntityManager()) {
Empleado pablo = em.find(Empleado.class, 120);
System.out.println("--- Proyectos de " + pablo.getNombreCompleto() + " ---");
for (Asignacion a : pablo.getAsignaciones()) {
System.out.println(" " + a.getProyecto().getNombre() + ": " + a.getFuncion()
+ ", " + a.getHoras() + " h");
}
System.out.println("--- Asignamos a Hugo (124) al proyecto 2 ---");
em.getTransaction().begin();
Empleado hugo = em.find(Empleado.class, 124);
Proyecto tienda = em.find(Proyecto.class, 2);
Asignacion nueva = new Asignacion(hugo, tienda, "Administrador de sistemas",
LocalDate.of(2026, 10, 4));
hugo.getAsignaciones().add(nueva); // sincronizamos los dos lados
tienda.getAsignaciones().add(nueva);
em.getTransaction().commit(); // cascade=ALL en Empleado.asignaciones
System.out.println("--- La quitamos (orphanRemoval) ---");
em.getTransaction().begin();
hugo.getAsignaciones().remove(nueva);
tienda.getAsignaciones().remove(nueva);
em.getTransaction().commit();
}
JpaUtil.cerrar();
}
}
Salida real (columnas abreviadas):
Hibernate:
select ... from empleados e1_0 where e1_0.emp_id=?
--- Proyectos de Pablo Moreno Herrera ---
Hibernate:
select
a1_0.emp_id,
a1_0.proyecto_id,
...
a1_0.horas
from
asignaciones a1_0
where
a1_0.emp_id=?
Hibernate:
select ... from proyectos p1_0 where p1_0.proyecto_id=?
Migración a la nube: Jefe de proyecto, 450 h
Hibernate:
select ... from proyectos p1_0 where p1_0.proyecto_id=?
Nueva tienda online: Jefe de proyecto, 300 h
--- Asignamos a Hugo (124) al proyecto 2 ---
Hibernate:
select ... from empleados e1_0 where e1_0.emp_id=?
Hibernate:
select ... from asignaciones a1_0 where a1_0.emp_id=?
Hibernate:
select ... from asignaciones a1_0 where a1_0.proyecto_id=?
Hibernate:
insert
into
asignaciones
(fecha_asignacion, funcion, horas, emp_id, proyecto_id)
values
(?, ?, ?, ?, ?)
--- La quitamos (orphanRemoval) ---
Hibernate:
delete
from
asignaciones
where
emp_id=?
and proyecto_id=?
El proyecto 2 no generó SELECT al hacer find: ya estaba en el contexto de persistencia porque lo habíamos cargado al recorrer las asignaciones de Pablo.
10. Autorrelaciones: jefes y categorías
Una autorrelación es una asociación de una entidad consigo misma. En EMPRESA hay dos: empleados.jefe_id → empleados.emp_id y categorias.categoria_padre_id → categorias.categoria_id. Se mapean exactamente igual que cualquier otra relación; simplemente, los dos extremos son la misma clase.
Figura 6. Autorrelación: la tabla tiene una clave ajena hacia sí misma y en memoria se forma un árbol.
@Entity
@Table(name = "categorias")
public class Categoria {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "categoria_id")
private Integer id;
@Column(nullable = false, length = 40, unique = true)
private String nombre;
// Autorrelación: la categoría padre está en la misma tabla
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "categoria_padre_id")
private Categoria padre;
@OneToMany(mappedBy = "padre")
@OrderBy("nombre")
private List<Categoria> hijas = new ArrayList<>();
@OneToMany(mappedBy = "categoria")
@OrderBy("nombre")
private List<Producto> productos = new ArrayList<>();
protected Categoria() { }
// getters...
}
💻 Ejemplo: Recorrido recursivo del árbol de categorías a partir de las raíces (las que no tienen padre). Esta vez desactivamos el log de SQL pasando una propiedad al crear la fábrica, para que se vea solo el árbol:
public class Ejemplo08Arbol {
static void imprimir(Categoria c, int nivel) {
System.out.println(" ".repeat(nivel) + "- " + c.getNombre());
for (Categoria hija : c.getHijas()) {
imprimir(hija, nivel + 1); // recursión por la autorrelación
}
}
public static void main(String[] args) {
// Sin el log de SQL para que se vea solo el árbol
try (var emf = Persistence.createEntityManagerFactory("empresa",
Map.of("hibernate.show_sql", "false"));
EntityManager em = emf.createEntityManager()) {
List<Categoria> raices = em.createQuery(
"select c from Categoria c where c.padre is null order by c.nombre", Categoria.class)
.getResultList();
raices.forEach(r -> imprimir(r, 0));
}
}
}
Salida real:
- Electrónica
- Audio
- Telefonía
- Informática
- Ordenadores sobremesa
- Periféricos
- Monitores
- Ratones y teclados
- Portátiles
- Oficina
- Mobiliario
⚠️ Importante: Este recorrido lanza una consulta por cada categoría para cargar sus hijas (12 en total). Con 11 categorías no importa; con un árbol de miles de nodos, sí. Para árboles grandes, carga todas las categorías con una sola consulta y construye el árbol en memoria, o usa una consulta recursiva
WITH RECURSIVEcon SQL nativo (módulo 16).
11. Colecciones: List, Set y @OrderBy
| Tipo | Clase que usa Hibernate | Semántica | Cuándo usarlo |
|---|---|---|---|
List sin índice | PersistentBag | Admite duplicados, orden no garantizado | @OneToMany con mappedBy cuando quieres un orden con @OrderBy |
List + @OrderColumn | PersistentList | Guarda la posición en una columna | Raro: cuando la posición es un dato (EMPRESA no la tiene) |
Set | PersistentSet | Sin duplicados (usa equals/hashCode) | @ManyToMany y colecciones de entidades con buena igualdad |
SortedSet + @SortNatural | PersistentSortedSet | Ordenado en memoria | Pocas veces |
Map + @MapKey | PersistentMap | Acceso por clave | Por ejemplo, asignaciones indexadas por proyecto |
| Anotación | Dónde ordena | Ejemplo |
|---|---|---|
@OrderBy("apellidos") | En la base de datos (ORDER BY) al cargar la colección | Departamento.empleados |
@OrderBy("id.numLinea") | Puede usar atributos de un @EmbeddedId | Pedido.lineas |
@OrderColumn(name = "posicion") | Guarda y lee una columna de posición | — |
⚠️ Importante: No reasignes nunca la colección de una entidad gestionada (
pedido.setLineas(new ArrayList<>())). Hibernate perdería el rastro de la colección original y, conorphanRemoval, lanzaría la excepción A collection with cascade=”all-delete-orphan” was no longer referenced. Modifica la colección existente (clear(),add(),remove()).
12. Herencia
Supón que EMPRESA quiere registrar cobros de los pedidos, que pueden ser con tarjeta (últimos dígitos y titular) o por transferencia (IBAN). En Java es natural una clase abstracta Pago con dos subclases. Pero las tablas no tienen herencia: hay que elegir cómo se guarda la jerarquía. JPA ofrece tres estrategias de @Inheritance y una cuarta opción, @MappedSuperclass.
💡 Consejo: Las tablas de esta sección (
pagos,pagos_tarjeta,pagos_transferencia,pagos_seq,avisos,tareas) no existen en EMPRESA: cada estrategia usa su propia unidad de persistencia conhibernate.hbm2ddl.auto = create-drop, así que Hibernate las crea al arrancar y las borra al cerrar. Es la mejor forma de ver el DDL que genera cada estrategia.
El programa de prueba es el mismo para las tres estrategias: guarda un pago de cada tipo, hace una consulta polimórfica (select p from Pago p, que devuelve objetos de las dos subclases) y una consulta de una sola subclase.
public class Ejemplo11Herencia {
public static void main(String[] args) {
String estrategia = args.length > 0 ? args[0] : "single";
LocalDate hoy = LocalDate.of(2026, 10, 4);
System.out.println("##### Estrategia: " + estrategia);
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-" + estrategia)) {
emf.runInTransaction(em -> {
// (se crea un PagoTarjeta y un PagoTransferencia del paquete de la estrategia)
em.persist(new PagoTarjeta(5022, new BigDecimal("1499.00"), hoy, "4321", "Diseños Creativos S.L."));
em.persist(new PagoTransferencia(5024, new BigDecimal("1497.00"), hoy, "ES9121000418450200051332"));
});
System.out.println("--- Consulta polimórfica: select p from Pago p ---");
emf.runInTransaction(em -> {
List<Pago> pagos = em.createQuery("select p from Pago p order by p.importe desc", Pago.class)
.getResultList();
for (Pago p : pagos) {
System.out.println(" " + p.getClass().getSimpleName() + " -> " + p.descripcion());
}
});
System.out.println("--- Consulta de una subclase: select t from PagoTarjeta t ---");
emf.runInTransaction(em ->
System.out.println(" Pagos con tarjeta: " + em.createQuery(
"select t from PagoTarjeta t", PagoTarjeta.class).getResultList().size()));
}
}
}
📘 Concepto: El ejemplo real del proyecto tiene las tres jerarquías en paquetes distintos (
herencia.single,herencia.joined,herencia.tpc) y elige la clase con unswitch; arriba se muestra simplificado. Las subclases son idénticas en las tres estrategias; solo cambian las anotaciones de la superclase y, enJOINEDyTABLE_PER_CLASS, el nombre de tabla de cada subclase.
12.1 SINGLE_TABLE: una tabla para toda la jerarquía
@Entity
@Table(name = "pagos")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // toda la jerarquía en UNA tabla
@DiscriminatorColumn(name = "tipo", length = 15) // columna que dice qué subclase es
public abstract class Pago {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "pedido_id", nullable = false)
private Integer pedidoId;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal importe;
@Column(nullable = false)
private LocalDate fecha;
protected Pago() { }
protected Pago(Integer pedidoId, BigDecimal importe, LocalDate fecha) {
this.pedidoId = pedidoId;
this.importe = importe;
this.fecha = fecha;
}
public abstract String descripcion();
// getters...
}
@Entity
@DiscriminatorValue("TARJETA")
public class PagoTarjeta extends Pago {
@Column(name = "ultimos_digitos", length = 4)
private String ultimosDigitos;
@Column(length = 60)
private String titular;
protected PagoTarjeta() { }
public PagoTarjeta(Integer pedidoId, BigDecimal importe, LocalDate fecha,
String ultimosDigitos, String titular) {
super(pedidoId, importe, fecha);
this.ultimosDigitos = ultimosDigitos;
this.titular = titular;
}
@Override
public String descripcion() { return "Tarjeta **** " + ultimosDigitos + " (" + titular + ")"; }
}
@Entity
@DiscriminatorValue("TRANSFERENCIA")
public class PagoTransferencia extends Pago {
@Column(length = 34)
private String iban;
protected PagoTransferencia() { }
public PagoTransferencia(Integer pedidoId, BigDecimal importe, LocalDate fecha, String iban) {
super(pedidoId, importe, fecha);
this.iban = iban;
}
@Override
public String descripcion() { return "Transferencia desde " + iban; }
}
Figura 7. Estrategia SINGLE_TABLE.
Salida real (sin los drop table con los que create-drop empieza y termina):
##### Estrategia: single
Hibernate:
create table pagos (
fecha date not null,
importe decimal(10,2) not null,
pedido_id integer not null,
ultimos_digitos varchar(4),
id bigint not null auto_increment,
tipo varchar(15) not null check ((tipo in ('TARJETA','TRANSFERENCIA'))),
iban varchar(34),
titular varchar(60),
primary key (id)
) engine=InnoDB
Hibernate:
insert
into
pagos
(fecha, importe, pedido_id, titular, ultimos_digitos, tipo)
values
(?, ?, ?, ?, ?, 'TARJETA')
Hibernate:
insert
into
pagos
(fecha, importe, pedido_id, iban, tipo)
values
(?, ?, ?, ?, 'TRANSFERENCIA')
--- Consulta polimórfica: select p from Pago p ---
Hibernate:
select
p1_0.id,
p1_0.tipo,
p1_0.fecha,
p1_0.importe,
p1_0.pedido_id,
p1_0.titular,
p1_0.ultimos_digitos,
p1_0.iban
from
pagos p1_0
order by
p1_0.importe desc
PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate:
select
pt1_0.id,
pt1_0.fecha,
pt1_0.importe,
pt1_0.pedido_id,
pt1_0.titular,
pt1_0.ultimos_digitos
from
pagos pt1_0
where
pt1_0.tipo='TARJETA'
Pagos con tarjeta: 1
El discriminador (tipo) lo rellena Hibernate con un literal en el INSERT y lo usa al leer para saber qué subclase instanciar. Las columnas de las subclases (ultimos_digitos, titular, iban) deben admitir NULL, porque en cada fila solo se usan las de una subclase.
12.2 JOINED: una tabla por clase
@Entity
@Table(name = "pagos")
@Inheritance(strategy = InheritanceType.JOINED) // una tabla por clase, unidas por la PK
public abstract class Pago {
// mismos atributos que en SINGLE_TABLE (sin discriminador)
}
@Entity
@Table(name = "pagos_tarjeta")
@PrimaryKeyJoinColumn(name = "pago_id") // PK que a la vez es FK hacia pagos.id
public class PagoTarjeta extends Pago {
@Column(name = "ultimos_digitos", nullable = false, length = 4)
private String ultimosDigitos;
@Column(nullable = false, length = 60)
private String titular;
// constructores y descripcion()
}
@Entity
@Table(name = "pagos_transferencia")
@PrimaryKeyJoinColumn(name = "pago_id")
public class PagoTransferencia extends Pago {
@Column(nullable = false, length = 34)
private String iban;
// constructores y descripcion()
}
Figura 8. Estrategia JOINED.
Salida real (al principio, Hibernate intenta borrar unas claves ajenas de tablas que todavía no existen y muestra dos avisos WARN ... Table 'pagos_tarjeta' doesn't exist con su traza; son inofensivos y se han omitido, igual que los drop table):
##### Estrategia: joined
Hibernate:
create table pagos (
fecha date not null,
importe decimal(10,2) not null,
pedido_id integer not null,
id bigint not null auto_increment,
primary key (id)
) engine=InnoDB
Hibernate:
create table pagos_tarjeta (
ultimos_digitos varchar(4) not null,
pago_id bigint not null,
titular varchar(60) not null,
primary key (pago_id)
) engine=InnoDB
Hibernate:
create table pagos_transferencia (
pago_id bigint not null,
iban varchar(34) not null,
primary key (pago_id)
) engine=InnoDB
Hibernate:
alter table pagos_tarjeta
add constraint FKcte1k9072j4x2eh2tyho97x72
foreign key (pago_id)
references pagos (id)
Hibernate:
alter table pagos_transferencia
add constraint FKr46m2ialb53xawgigr8ue2yul
foreign key (pago_id)
references pagos (id)
Hibernate:
insert
into
pagos
(fecha, importe, pedido_id)
values
(?, ?, ?)
Hibernate:
insert
into
pagos_tarjeta
(titular, ultimos_digitos, pago_id)
values
(?, ?, ?)
Hibernate:
insert
into
pagos
(fecha, importe, pedido_id)
values
(?, ?, ?)
Hibernate:
insert
into
pagos_transferencia
(iban, pago_id)
values
(?, ?)
--- Consulta polimórfica: select p from Pago p ---
Hibernate:
select
p1_0.id,
case
when p1_1.pago_id is not null
then 1
when p1_2.pago_id is not null
then 2
end,
p1_0.fecha,
p1_0.importe,
p1_0.pedido_id,
p1_1.titular,
p1_1.ultimos_digitos,
p1_2.iban
from
pagos p1_0
left join
pagos_tarjeta p1_1
on p1_0.id=p1_1.pago_id
left join
pagos_transferencia p1_2
on p1_0.id=p1_2.pago_id
order by
p1_0.importe desc
PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate:
select
pt1_0.pago_id,
pt1_1.fecha,
pt1_1.importe,
pt1_1.pedido_id,
pt1_0.titular,
pt1_0.ultimos_digitos
from
pagos_tarjeta pt1_0
join
pagos pt1_1
on pt1_0.pago_id=pt1_1.id
Pagos con tarjeta: 1
Cada pago necesita dos INSERT (uno en pagos y otro en la tabla de su subclase) y la consulta polimórfica hace un LEFT JOIN por subclase y un CASE para averiguar el tipo. A cambio, el esquema está normalizado y las columnas de las subclases pueden ser NOT NULL.
12.3 TABLE_PER_CLASS: una tabla completa por clase concreta
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) // una tabla COMPLETA por clase concreta
public abstract class Pago {
// IDENTITY no sirve aquí: los ids deben ser únicos entre todas las tablas
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "gen_pagos")
@SequenceGenerator(name = "gen_pagos", sequenceName = "pagos_seq", allocationSize = 1)
private Long id;
// resto de atributos comunes
}
@Entity
@Table(name = "pagos_tarjeta")
public class PagoTarjeta extends Pago { /* ultimosDigitos, titular */ }
@Entity
@Table(name = "pagos_transferencia")
public class PagoTransferencia extends Pago { /* iban */ }
Figura 9. Estrategia TABLE_PER_CLASS.
Salida real (sin los drop table iniciales y finales):
##### Estrategia: tpc
Hibernate:
create table pagos_seq (
next_val bigint
) engine=InnoDB
Hibernate:
insert into pagos_seq ( next_val ) values ( 1 )
Hibernate:
create table pagos_tarjeta (
fecha date not null,
importe decimal(10,2) not null,
pedido_id integer not null,
ultimos_digitos varchar(4) not null,
id bigint not null,
titular varchar(60) not null,
primary key (id)
) engine=InnoDB
Hibernate:
create table pagos_transferencia (
fecha date not null,
importe decimal(10,2) not null,
pedido_id integer not null,
id bigint not null,
iban varchar(34) not null,
primary key (id)
) engine=InnoDB
Hibernate:
select
next_val as id_val
from
pagos_seq
for
update
Hibernate:
update
pagos_seq
set
next_val= ?
where
next_val=?
(... lo mismo para el segundo pago ...)
Hibernate:
insert
into
pagos_tarjeta
(fecha, importe, pedido_id, titular, ultimos_digitos, id)
values
(?, ?, ?, ?, ?, ?)
Hibernate:
insert
into
pagos_transferencia
(fecha, importe, pedido_id, iban, id)
values
(?, ?, ?, ?, ?)
--- Consulta polimórfica: select p from Pago p ---
Hibernate:
select
p1_0.id,
p1_0.clazz_,
p1_0.fecha,
p1_0.importe,
p1_0.pedido_id,
p1_0.titular,
p1_0.ultimos_digitos,
p1_0.iban
from
(select
fecha,
importe,
pedido_id,
ultimos_digitos,
id,
titular,
null as iban,
1 as clazz_
from
pagos_tarjeta
union
all select
fecha,
importe,
pedido_id,
null as ultimos_digitos,
id,
null as titular,
iban,
2 as clazz_
from
pagos_transferencia
) p1_0
order by
p1_0.importe desc
PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate:
select
pt1_0.id,
pt1_0.fecha,
pt1_0.importe,
pt1_0.pedido_id,
pt1_0.titular,
pt1_0.ultimos_digitos
from
pagos_tarjeta pt1_0
Pagos con tarjeta: 1
MySQL no tiene secuencias, así que Hibernate emula pagos_seq con una tabla de una fila que bloquea (for update) e incrementa. La consulta polimórfica une todas las tablas con UNION ALL, rellenando con null las columnas que no tiene cada una. La consulta de una subclase, en cambio, es la más sencilla de las tres estrategias.
12.4 @MappedSuperclass: herencia de código, no de tablas
A veces no quieres una jerarquía de entidades, sino reutilizar atributos comunes (id, fechas de auditoría, versión…) en varias entidades sin relación entre sí. Para eso está @MappedSuperclass: la superclase no es una entidad, no tiene tabla y no se puede consultar, pero sus atributos y anotaciones se copian en cada subclase.
/** No es una entidad: solo aporta atributos (y su mapeo) a sus subclases. */
@MappedSuperclass
public abstract class EntidadAuditada {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "creado_en", nullable = false, updatable = false)
private LocalDateTime creadoEn;
@Column(name = "modificado_en")
private LocalDateTime modificadoEn;
@PrePersist // callback: justo antes del INSERT
protected void alCrear() {
creadoEn = LocalDateTime.of(2026, 10, 4, 10, 0); // en la práctica: LocalDateTime.now()
}
@PreUpdate // callback: justo antes de cada UPDATE
protected void alModificar() {
modificadoEn = LocalDateTime.of(2026, 10, 4, 12, 30);
}
public Long getId() { return id; }
public LocalDateTime getCreadoEn() { return creadoEn; }
public LocalDateTime getModificadoEn() { return modificadoEn; }
}
@Entity
@Table(name = "avisos")
public class Aviso extends EntidadAuditada {
@Column(nullable = false, length = 200)
private String texto;
// ...
}
@Entity
@Table(name = "tareas")
public class Tarea extends EntidadAuditada {
@Column(nullable = false, length = 100)
private String titulo;
private boolean hecha;
// ...
}
Figura 10. @MappedSuperclass.
public class Ejemplo12Mapped {
public static void main(String[] args) {
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-mapped")) {
Long id = emf.callInTransaction(em -> {
Aviso a = new Aviso("Inventario anual el día 15");
em.persist(a); // @PrePersist rellena creadoEn
em.persist(new Tarea("Revisar stock de monitores"));
return a.getId();
});
emf.runInTransaction(em -> em.find(Aviso.class, id).setTexto("Inventario anual el día 16"));
emf.runInTransaction(em -> {
Aviso a = em.find(Aviso.class, id);
System.out.println(a.getTexto() + " | creado " + a.getCreadoEn()
+ " | modificado " + a.getModificadoEn());
});
// Una @MappedSuperclass NO es una entidad: no se puede consultar
try {
emf.runInTransaction(em -> em.createQuery("select e from EntidadAuditada e").getResultList());
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
}
Salida real (sin los drop table iniciales y finales):
Hibernate:
create table avisos (
creado_en datetime(6) not null,
id bigint not null auto_increment,
modificado_en datetime(6),
texto varchar(200) not null,
primary key (id)
) engine=InnoDB
Hibernate:
create table tareas (
hecha bit not null,
creado_en datetime(6) not null,
id bigint not null auto_increment,
modificado_en datetime(6),
titulo varchar(100) not null,
primary key (id)
) engine=InnoDB
Hibernate:
insert
into
avisos
(creado_en, modificado_en, texto)
values
(?, ?, ?)
Hibernate:
insert
into
tareas
(creado_en, hecha, modificado_en, titulo)
values
(?, ?, ?, ?)
Hibernate:
select ... from avisos a1_0 where a1_0.id=?
Hibernate:
update
avisos
set
modificado_en=?,
texto=?
where
id=?
Hibernate:
select ... from avisos a1_0 where a1_0.id=?
Inventario anual el día 16 | creado 2026-10-04T10:00 | modificado 2026-10-04T12:30
Error: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'EntidadAuditada'
Dos detalles: el UPDATE no incluye creado_en porque la marcamos con updatable = false, y @PreUpdate ha rellenado modificado_en sin que el código de negocio lo pida. Los callbacks del ciclo de vida (@PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad) son muy útiles para la auditoría.
12.5 ¿Qué estrategia elijo?
| Criterio | SINGLE_TABLE | JOINED | TABLE_PER_CLASS | @MappedSuperclass |
|---|---|---|---|---|
| Tablas | 1 | 1 + una por subclase | Una por clase concreta | Una por entidad |
| Consulta polimórfica | Rápida (sin JOIN) | LEFT JOIN por subclase | UNION ALL | No se puede |
| Consulta de una subclase | WHERE tipo = ... | JOIN con la tabla padre | Una tabla | Una tabla |
INSERT | 1 | 2 | 1 | 1 |
NOT NULL en atributos de subclase | No | Sí | Sí | Sí |
Relaciones hacia la superclase (@ManyToOne Pago) | Sí | Sí | Sí (con UNION) | No |
| Generación de id | Cualquiera | Cualquiera | No IDENTITY | Cualquiera |
| Cuándo usarla | Por defecto: pocas subclases y pocos atributos propios | Subclases con muchos atributos propios e integridad estricta | Casi nunca | Atributos comunes sin polimorfismo |
💡 Consejo: Empieza por
SINGLE_TABLE. Pásate aJOINEDsi las subclases tienen muchos atributos propios o necesitas restriccionesNOT NULLen ellos.TABLE_PER_CLASSrara vez compensa. Y si en realidad no necesitas consultas polimórficas,@MappedSuperclasses lo más simple.
13. equals y hashCode en entidades
Las entidades acaban en Set (colecciones @ManyToMany, @OneToMany con Set) y en el propio contexto de persistencia, así que su igualdad importa. El problema es que el id de una entidad nueva es null hasta el persist y después cambia.
public class Ejemplo13EqualsHashCode {
/** Entidad de ejemplo (no persistida) con equals/hashCode basados en el id generado. */
static class Mala {
Integer id;
@Override public boolean equals(Object o) { return o instanceof Mala m && Objects.equals(id, m.id); }
@Override public int hashCode() { return Objects.hashCode(id); }
}
/** Patrón recomendado: igualdad por id, pero hashCode constante por clase. */
static class Buena {
Integer id;
@Override public boolean equals(Object o) {
return o instanceof Buena b && id != null && id.equals(b.id);
}
@Override public int hashCode() { return Buena.class.hashCode(); }
}
public static void main(String[] args) {
Set<Mala> malas = new HashSet<>();
Mala m = new Mala();
malas.add(m); // se guarda con hash(null)
m.id = 42; // persist() asigna el id -> cambia el hashCode
System.out.println("Mala: contains = " + malas.contains(m) + ", size = " + malas.size());
Set<Buena> buenas = new HashSet<>();
Buena b = new Buena();
buenas.add(b);
b.id = 42;
System.out.println("Buena: contains = " + buenas.contains(b) + ", size = " + buenas.size());
Buena b1 = new Buena(), b2 = new Buena(); // dos objetos nuevos (sin id)
System.out.println("Dos nuevas sin id iguales? " + b1.equals(b2));
}
}
Salida real:
Mala: contains = false, size = 1
Buena: contains = true, size = 1
Dos nuevas sin id iguales? false
Con la versión “mala”, el objeto queda perdido dentro del Set: está ahí (size = 1), pero contains no lo encuentra porque se guardó en el “cajón” de hash(null) y ahora se busca en el de hash(42).
| Estrategia | Ventajas | Inconvenientes |
|---|---|---|
| No sobrescribir (identidad de objeto) | Sencilla; válida dentro de un mismo EntityManager | Dos copias de la misma fila (de distintos EntityManager) no son iguales |
Clave natural inmutable (Habilidad.nombre, NIF, email) | La mejor si existe | Hace falta una clave natural que no cambie nunca |
Id + hashCode constante por clase (Buena) | Siempre correcta | Todos los objetos de la clase caen en el mismo “cajón” del HashSet (aceptable para colecciones pequeñas) |
Id generado en hashCode (Mala) | — | Incorrecta con ids generados por la base de datos |
⚠️ Importante: Nunca uses en
equals/hashCodeatributos que sean relaciones perezosas (provocarían consultas oLazyInitializationException) ni colecciones. Y no uses un record niObjects.hash(todosLosAtributos)para una entidad: sus atributos cambian.
14. Buenas prácticas y errores comunes
| Error o síntoma | Causa | Solución |
|---|---|---|
La clave ajena se guarda como NULL | Solo se modificó el lado inverso (mappedBy) | Modifica el lado propietario; usa métodos add/remove |
LazyInitializationException ... no session | Acceso a un proxy o colección LAZY con el EntityManager cerrado | JOIN FETCH, @EntityGraph, DTO, o acceder antes de cerrar |
| Muchas consultas inesperadas al listar | Relaciones EAGER o acceso LAZY en bucle (N+1) | Todo LAZY + JOIN FETCH donde haga falta (módulo 16) |
TransientPropertyValueException: ... references an unsaved transient instance | Se guarda una entidad que apunta a otra nueva que no se ha persistido | persist de la otra antes, o cascada PERSIST si es composición |
| Borrar un hijo borra datos que no debía | CascadeType.REMOVE/ALL en @ManyToOne o @ManyToMany | Cascadas solo de padre a hijos en composiciones |
A collection with cascade="all-delete-orphan" was no longer referenced | Se reemplazó la colección con setXxx(new ...) | Modifica la colección existente |
@ManyToMany borra y reinserta todas las filas | Colección List (bag) | Usa Set |
Bucle infinito en toString()/JSON | Relación bidireccional que se imprime en ambos sentidos | Excluye las colecciones y relaciones de toString; usa DTO |
Cannot use identity column key generation with <union-subclass> | IDENTITY con TABLE_PER_CLASS | Usa SEQUENCE o TABLE |
💡 Consejo: Resumen de reglas de oro: (1) todas las relaciones LAZY; (2) bidireccional solo si hace falta, con métodos de sincronización; (3) cascadas solo en composiciones; (4)
Setpara@ManyToMany; (5) N:M con datos = entidad intermedia; (6)SINGLE_TABLEcomo primera opción de herencia; (7) cuidado conequals/hashCode; (8) mira siempre el SQL.
15. Ejercicios
Los ejercicios usan el modelo completo de este módulo. Para que la salida sea breve, los ejercicios 1 a 5 crean la fábrica sin el log de SQL con esta pequeña clase auxiliar:
/** Fábrica de la unidad "empresa" sin el log de SQL, para que la salida de los ejercicios sea breve. */
final class SinSql {
private SinSql() { }
static EntityManagerFactory emf() {
return Persistence.createEntityManagerFactory("empresa", Map.of("hibernate.show_sql", "false"));
}
}
Ejercicio 1: Resumen por departamento navegando relaciones
Sin escribir ninguna consulta, solo con find y navegando por las colecciones, muestra para cada departamento (10 a 70) su número de empleados, la suma de sus salarios y su número de proyectos.
Ver solución
public class Ejercicio1 {
public static void main(String[] args) {
try (EntityManagerFactory emf = SinSql.emf()) {
emf.runInTransaction(em -> {
System.out.printf("%-14s %9s %12s %9s%n", "DEPARTAMENTO", "EMPLEADOS", "SALARIOS", "PROYECTOS");
for (int id = 10; id <= 70; id += 10) {
Departamento d = em.find(Departamento.class, id);
BigDecimal masa = d.getEmpleados().stream()
.map(Empleado::getSalario)
.reduce(BigDecimal.ZERO, BigDecimal::add);
System.out.printf("%-14s %9d %12s %9d%n", d.getNombre(),
d.getEmpleados().size(), masa, d.getProyectos().size());
}
});
}
}
}
Salida real:
DEPARTAMENTO EMPLEADOS SALARIOS PROYECTOS
DIRECCIÓN 1 6500.00 1
VENTAS 6 15000.00 0
INFORMÁTICA 6 18800.00 2
CONTABILIDAD 3 8450.00 0
MARKETING 3 9300.00 1
LOGÍSTICA 4 8950.00 1
INVESTIGACIÓN 0 0 0
Funciona, pero cuesta 21 consultas (7 find + 7 colecciones de empleados + 7 de proyectos). Navegar por relaciones es cómodo para una entidad; para un informe, en el módulo 16 harás lo mismo con una única consulta JPQL con GROUP BY. El 0 de INVESTIGACIÓN es BigDecimal.ZERO (la reducción de una colección vacía), por eso no tiene decimales.
Ejercicio 2: Organigrama recursivo
A partir de la directora general (empleado 100), imprime el organigrama completo de la empresa recorriendo subordinados recursivamente, con sangría por nivel y el oficio y departamento de cada persona (“sin departamento” si no tiene). Cuenta el total de personas.
Ver solución
public class Ejercicio2 {
static int contar(Empleado e, int nivel) {
String depto = e.getDepartamento() == null ? "sin departamento" : e.getDepartamento().getNombre();
System.out.println(" ".repeat(nivel) + e.getNombreCompleto() + " - " + e.getOficio() + " (" + depto + ")");
int total = 1;
for (Empleado sub : e.getSubordinados()) {
total += contar(sub, nivel + 1);
}
return total;
}
public static void main(String[] args) {
try (EntityManagerFactory emf = SinSql.emf()) {
emf.runInTransaction(em -> {
Empleado ana = em.find(Empleado.class, 100);
int total = contar(ana, 0);
System.out.println("Personas en el organigrama: " + total);
Empleado marta = em.find(Empleado.class, 102);
System.out.println("Subordinados directos de " + marta.getNombre() + ": "
+ marta.getSubordinados().size());
});
}
}
}
Salida real:
Ana García López - DIRECTOR GENERAL (DIRECCIÓN)
Luis Martínez Ruiz - DIRECTOR (VENTAS)
Carmen Ruiz Ortega - VENDEDOR (VENTAS)
David Torres Romero - VENDEDOR (VENTAS)
Elena Navarro Castro - VENDEDOR (VENTAS)
Sergio Jiménez Vidal - VENDEDOR (VENTAS)
Lucía Romero Serrano - ADMINISTRATIVO (VENTAS)
Marta Sánchez Gil - DIRECTOR (INFORMÁTICA)
Pablo Moreno Herrera - ANALISTA (INFORMÁTICA)
Raquel Molina Prieto - PROGRAMADOR (INFORMÁTICA)
Iván Castro Méndez - PROGRAMADOR (INFORMÁTICA)
Sara Ortiz Delgado - PROGRAMADOR (INFORMÁTICA)
Hugo Rubio Marín - TÉCNICO SISTEMAS (INFORMÁTICA)
Tomás Reyes Fuentes - BECARIO (sin departamento)
Pedro Gómez Díaz - DIRECTOR (CONTABILIDAD)
Isabel Domínguez Vega - CONTABLE (CONTABILIDAD)
Jorge Vázquez Ramos - ADMINISTRATIVO (CONTABILIDAD)
Laura Fernández Moreno - DIRECTOR (MARKETING)
Nuria Gil Soto - DISEÑADOR (MARKETING)
Álvaro Peña Cano - ANALISTA (MARKETING)
Javier López Navarro - DIRECTOR (LOGÍSTICA)
Rosa Iglesias Cruz - OPERARIO (LOGÍSTICA)
Manuel Herrero Lozano - OPERARIO (LOGÍSTICA)
Cristina Medina Blanco - ADMINISTRATIVO (LOGÍSTICA)
Personas en el organigrama: 24
Subordinados directos de Marta: 3
La recursión sigue la autorrelación hacia abajo (subordinados, ordenados por id gracias a @OrderBy("id")). Salen los 24 empleados, así que nadie está “suelto” sin jefe salvo Ana. El find de Marta no genera consulta: ya está en el contexto de persistencia. Tomás aparece “sin departamento” porque su departamento es null, y hay que comprobarlo antes de pedir el nombre.
Ejercicio 3: Pedido completo con cascada
Crea para la clienta 1003 (María José Herrera), con el vendedor 111, un pedido de 1 portátil básico (producto 3), 2 ratones (6) y 1 auriculares (13) con un 10 % de descuento en los auriculares, guardando solo el pedido. En otra transacción, muéstralo con sus líneas, el total y el número de pedidos de la clienta. Después bórralo y comprueba con una consulta que no quedan líneas.
Ver solución
public class Ejercicio3 {
public static void main(String[] args) {
try (EntityManagerFactory emf = SinSql.emf()) {
Integer id = emf.callInTransaction(em -> {
Cliente cliente = em.find(Cliente.class, 1003);
Empleado vendedor = em.getReference(Empleado.class, 111);
Pedido p = new Pedido(cliente, vendedor, LocalDate.of(2026, 10, 4));
p.addLinea(em.find(Producto.class, 3), 1, BigDecimal.ZERO);
p.addLinea(em.find(Producto.class, 6), 2, BigDecimal.ZERO);
p.addLinea(em.find(Producto.class, 13), 1, new BigDecimal("10"));
em.persist(p); // las líneas se guardan en cascada
return p.getId();
});
emf.runInTransaction(em -> {
Pedido p = em.find(Pedido.class, id);
System.out.println("Pedido " + p.getId() + " de " + p.getCliente().getNombre()
+ " (vendedor: " + p.getEmpleado().getNombreCompleto() + ")");
p.getLineas().forEach(l -> System.out.println(" " + l));
System.out.println(" TOTAL: " + p.getTotal() + " €");
System.out.println("Pedidos de este cliente: " + p.getCliente().getPedidos().size());
});
emf.runInTransaction(em -> em.remove(em.find(Pedido.class, id))); // y se borran en cascada
Long lineas = emf.callInTransaction(em -> em.createQuery(
"select count(l) from LineaPedido l where l.pedido.id = :id", Long.class)
.setParameter("id", id).getSingleResult());
System.out.println("Líneas que quedan del pedido " + id + ": " + lineas);
}
}
}
Salida real:
Pedido 5026 de María José Herrera (vendedor: David Torres Romero)
Linea 1: 1 x Portátil Básico 15 = 499.00
Linea 2: 2 x Ratón inalámbrico = 39.80
Linea 3: 1 x Auriculares Bluetooth = 53.91
TOTAL: 592.71 €
Pedidos de este cliente: 3
Líneas que quedan del pedido 5026: 0
addLinea toma el precio actual del producto y numera las líneas; un único persist(p) guarda el pedido y sus tres líneas (cascada PERSIST), y un único remove borra todo (cascada REMOVE). La clienta tenía ya dos pedidos (5003 y 5015), por eso ahora tiene tres. El número del pedido depende del AUTO_INCREMENT de tu base de datos.
Ejercicio 4: Árbol de categorías con totales
Imprime el árbol de categorías con, para cada una, el número de productos y el stock total incluyendo todas sus subcategorías. Usa dos métodos recursivos: uno que calcule los totales y otro que imprima.
Ver solución
public class Ejercicio4 {
/** Devuelve {productos, stock} de la categoría sumando todas sus descendientes. */
static int[] totales(Categoria c) {
int productos = c.getProductos().size();
int stock = c.getProductos().stream().mapToInt(Producto::getStock).sum();
for (Categoria hija : c.getHijas()) {
int[] t = totales(hija);
productos += t[0];
stock += t[1];
}
return new int[] { productos, stock };
}
static void imprimir(Categoria c, int nivel) {
int[] t = totales(c);
System.out.printf("%-28s %2d productos, stock %4d%n",
" ".repeat(nivel) + c.getNombre(), t[0], t[1]);
c.getHijas().forEach(h -> imprimir(h, nivel + 1));
}
public static void main(String[] args) {
try (EntityManagerFactory emf = SinSql.emf()) {
emf.runInTransaction(em -> {
for (int raiz : new int[] { 1, 7, 10 }) {
imprimir(em.find(Categoria.class, raiz), 0);
}
});
}
}
}
Salida real:
Informática 13 productos, stock 510
Ordenadores sobremesa 2 productos, stock 19
Periféricos 7 productos, stock 346
Monitores 2 productos, stock 47
Ratones y teclados 3 productos, stock 260
Portátiles 3 productos, stock 75
Electrónica 5 productos, stock 152
Audio 2 productos, stock 80
Telefonía 2 productos, stock 58
Oficina 2 productos, stock 26
Mobiliario 2 productos, stock 26
Fíjate en que los números de un padre no son solo la suma de sus hijas: Informática tiene 13 productos porque al disco SSD (producto 19) se le asignó directamente la categoría 1, y Periféricos incluye la webcam y la impresora, que cuelgan de ella sin subcategoría. totales se recalcula para cada nodo, pero no genera consultas nuevas: las colecciones ya cargadas se reutilizan del contexto de persistencia.
Ejercicio 5: Asignaciones de un departamento
Para cada empleado de INFORMÁTICA (departamento 30), muestra el total de horas asignadas y la lista de sus proyectos (nombre, función, horas y fecha), ordenados por fecha de asignación. Indica “(sin proyectos)” cuando no tenga ninguno.
Ver solución
public class Ejercicio5 {
public static void main(String[] args) {
try (EntityManagerFactory emf = SinSql.emf()) {
emf.runInTransaction(em -> {
Departamento informatica = em.find(Departamento.class, 30);
for (Empleado e : informatica.getEmpleados()) {
int horas = e.getAsignaciones().stream().mapToInt(Asignacion::getHoras).sum();
System.out.println(e.getNombreCompleto() + " - " + horas + " h");
if (e.getAsignaciones().isEmpty()) {
System.out.println(" (sin proyectos)");
}
e.getAsignaciones().stream()
.sorted(Comparator.comparing(Asignacion::getFechaAsignacion))
.forEach(a -> System.out.println(" " + a.getProyecto().getNombre()
+ " [" + a.getFuncion() + "] " + a.getHoras() + " h desde "
+ a.getFechaAsignacion()));
}
});
}
}
}
Salida real:
Iván Castro Méndez - 520 h
Nueva tienda online [Desarrollador] 520 h desde 2025-06-01
Raquel Molina Prieto - 600 h
Migración a la nube [Desarrollador] 600 h desde 2025-01-15
Pablo Moreno Herrera - 750 h
Migración a la nube [Jefe de proyecto] 450 h desde 2025-01-07
Nueva tienda online [Jefe de proyecto] 300 h desde 2025-06-01
Sara Ortiz Delgado - 410 h
Nueva tienda online [Desarrollador] 410 h desde 2025-07-01
Hugo Rubio Marín - 380 h
Migración a la nube [Administrador de sistemas] 380 h desde 2025-02-01
Marta Sánchez Gil - 0 h
(sin proyectos)
Como asignaciones es un Set (sin orden), ordenamos en memoria con un Comparator del módulo 03. Los empleados salen por apellidos gracias al @OrderBy de Departamento.empleados. Desde cada Asignacion se navega al Proyecto: es la ventaja de haber convertido la N:M en una entidad con dos @ManyToOne.
Ejercicio 6: Una subclase nueva en SINGLE_TABLE
Añade a la jerarquía SINGLE_TABLE una subclase PagoBizum (discriminador BIZUM, atributo telefono). Crea una unidad de persistencia de laboratorio herencia-ejercicio con las cuatro clases y create-drop, guarda un pago de cada tipo (dos de Bizum), lista todos los pagos con su descripción, calcula el total por tipo y cuenta los pagos Bizum con JPQL.
Ver solución
@Entity
@DiscriminatorValue("BIZUM")
public class PagoBizum extends Pago {
@Column(length = 15)
private String telefono;
protected PagoBizum() { }
public PagoBizum(Integer pedidoId, BigDecimal importe, LocalDate fecha, String telefono) {
super(pedidoId, importe, fecha);
this.telefono = telefono;
}
@Override
public String descripcion() { return "Bizum desde el " + telefono; }
}
<persistence-unit name="herencia-ejercicio" transaction-type="RESOURCE_LOCAL">
<class>es.curso.empresa.herencia.single.Pago</class>
<class>es.curso.empresa.herencia.single.PagoTarjeta</class>
<class>es.curso.empresa.herencia.single.PagoTransferencia</class>
<class>es.curso.empresa.herencia.single.PagoBizum</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/empresa"/>
<property name="jakarta.persistence.jdbc.user" value="curso"/>
<property name="jakarta.persistence.jdbc.password" value="curso"/>
<property name="hibernate.hbm2ddl.auto" value="create-drop"/>
</properties>
</persistence-unit>
public class Ejercicio6 {
public static void main(String[] args) {
LocalDate hoy = LocalDate.of(2026, 10, 4);
try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-ejercicio")) {
emf.runInTransaction(em -> {
em.persist(new PagoTarjeta(5022, new BigDecimal("1499.00"), hoy, "4321", "Diseños Creativos S.L."));
em.persist(new PagoTransferencia(5024, new BigDecimal("1497.00"), hoy, "ES9121000418450200051332"));
em.persist(new PagoBizum(5023, new BigDecimal("418.80"), hoy, "600123123"));
em.persist(new PagoBizum(5021, new BigDecimal("50.00"), hoy, "600456456"));
});
emf.runInTransaction(em -> {
List<Pago> pagos = em.createQuery("select p from Pago p", Pago.class).getResultList();
Map<String, BigDecimal> porTipo = new TreeMap<>();
for (Pago p : pagos) {
porTipo.merge(p.getClass().getSimpleName(), p.getImporte(), BigDecimal::add);
System.out.println(p.getId() + " " + p.descripcion());
}
System.out.println("Total por tipo: " + porTipo);
long bizums = em.createQuery("select count(b) from PagoBizum b", Long.class).getSingleResult();
System.out.println("Pagos Bizum: " + bizums);
});
}
}
}
Salida real:
1 Tarjeta **** 4321 (Diseños Creativos S.L.)
2 Transferencia desde ES9121000418450200051332
3 Bizum desde el 600123123
4 Bizum desde el 600456456
Total por tipo: {PagoBizum=468.80, PagoTarjeta=1499.00, PagoTransferencia=1497.00}
Pagos Bizum: 2
Añadir una subclase a SINGLE_TABLE solo añade una columna (telefono) y un valor posible del discriminador; el código que trabaja con Pago no cambia (polimorfismo: cada objeto responde a descripcion() a su manera). Map.merge acumula los importes por tipo y el TreeMap los ordena por nombre.
Ejercicios propuestos
Ejercicio 7: Pedido → Cliente bidireccional
Añade a Cliente los métodos addPedido/removePedido y un método getTotalComprado() que sume getTotal() de sus pedidos no cancelados. Muestra el total comprado por los clientes 1001, 1002 y 1010. ¿Cuántas consultas genera? Anótalo para compararlo con la versión JPQL del módulo 16.
Ejercicio 8: Habilidades de un proyecto
Usando las tablas de laboratorio, escribe un método que, dado un proyecto, devuelva el conjunto (Set<Habilidad>) de habilidades que reúne su equipo (las de todos los empleados asignados). Pruébalo con los proyectos 1 y 2.
Ejercicio 9: JOINED con una tercera subclase
Repite el ejercicio 6 con la estrategia JOINED. Compara el DDL y las consultas con las de SINGLE_TABLE: ¿cuántas tablas y cuántos JOIN aparecen ahora?
Ejercicio 10: Mover empleados entre departamentos
Escribe un método trasladar(int empId, int deptDestino) que use removeEmpleado/addEmpleado para mover a un empleado de departamento manteniendo los dos lados coherentes. Comprueba en la misma transacción (sin releer) que ambas colecciones son correctas y después revierte el cambio.
Ejercicio 11: Auditoría con @MappedSuperclass y @EntityListeners
Crea una clase AuditoriaListener con métodos @PrePersist y @PreUpdate que reciban un Object y, si es una EntidadAuditada, rellenen sus fechas con LocalDateTime.now(). Regístrala con @EntityListeners(AuditoriaListener.class) en la superclase y elimina los métodos de callback de EntidadAuditada. ¿Qué ventaja tiene sacar esa lógica a una clase aparte?
Ejercicio 12: Comentarios de pedidos con @ElementCollection
Investiga @ElementCollection y @CollectionTable y úsalos para añadir a Pedido una lista de notas (List<String>) guardada en una tabla de laboratorio pedido_notas (pedido_id, nota). Crea la tabla con un script SQL, prueba a añadir y quitar notas observando el SQL, y bórrala al terminar. ¿En qué se diferencia de una relación @OneToMany con una entidad Nota?
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Muchos a uno | @ManyToOne(fetch = LAZY) @JoinColumn(name = "dept_id") (lado propietario) |
| Uno a muchos | @OneToMany(mappedBy = "departamento") (lado inverso) |
| Lado propietario | El que tiene la FK (@JoinColumn, @JoinTable, @MapsId); el único que se escribe |
| Sincronizar | Métodos addXxx/removeXxx que actualizan ambos lados |
| Uno a uno | @OneToOne(fetch = LAZY) + @MapsId (PK compartida) |
| Muchos a muchos | @ManyToMany + @JoinTable(joinColumns, inverseJoinColumns), con Set |
| N:M con atributos | Entidad intermedia + @EmbeddedId + dos @ManyToOne con @MapsId |
| Autorrelación | @ManyToOne Empleado jefe + @OneToMany(mappedBy = "jefe") |
| Carga | LAZY en todo; EAGER por defecto en @ManyToOne/@OneToOne |
| Proxy | getReference, relaciones LAZY; se inicializa al acceder (EM abierto) |
LazyInitializationException | Acceso LAZY con el EM cerrado: JOIN FETCH, Hibernate.initialize, DTO |
| Cascadas | cascade = CascadeType.ALL solo en composición (padre → hijos) |
| Huérfanos | orphanRemoval = true: quitar de la colección = DELETE |
| Orden | @OrderBy("apellidos"), @OrderBy("id.numLinea") |
| Herencia | @Inheritance(SINGLE_TABLE \| JOINED \| TABLE_PER_CLASS), @DiscriminatorColumn/Value, @PrimaryKeyJoinColumn |
| Atributos comunes | @MappedSuperclass (no es entidad, no se consulta) |
| Callbacks | @PrePersist, @PreUpdate, @PostLoad… |
equals/hashCode | Clave natural, o id con hashCode constante; nunca relaciones LAZY |