Introducción al problema
Al trabajar con relaciones entre entidades en Hibernate, puede ocurrir que una colección de objetos asociados no se recupere completamente. Consideremos un escenario donde una entidad Order tiene una lista de OrderItem, pero al obtener la colección, algunos elementos desaparecen misteriosamente.
Order pedido = orderDao.findById(201L);
// La base de datos contiene 4 líneas de pedido, pero solo se obtienen 2
List<OrderItem> lineas = pedido.getItems();
La causa raíz reside en cómo se configura la carga de la relación inversa. Si OrderItem declara su relación con Product con FetchType.EAGER y nullable = false, Hibernate genera un INNER JOIN que excluye los registros con claves foráneas nulas o inexistentes.
Configuración de FetchType en JPA
JPA permite definir cuatro tipos de relaciones: @OneToOne, @ManyToOne, @OneToMany y @ManyToMany. Cada una admite un atributo fetch con dos valores posibles:
- FetchType.LAZY: la carga se difiere hasta que se accede a la propiedad.
- FetchType.EAGER: la carga se ejecuta inmediatamente junto con la entidad principal, generando joins en la consulta SQL.
El modo EAGER puede provocar pérdidas de registros cuando hay valores nulos o huérfanos en la columna de unión, ya que Hibernate utiliza INNER JOIN por defecto cuando nullable = false.
Reprodución del problema
Esquema de base de datos
-- Tabla de pedidos
CREATE TABLE T_ORDER (
id INTEGER PRIMARY KEY
);
-- Tabla de líneas de pedido
CREATE TABLE T_ORDER_ITEM (
id INTEGER PRIMARY KEY,
order_id INTEGER,
product_id INTEGER,
quantity INTEGER
);
-- Tabla de productos
CREATE TABLE T_PRODUCT (
id INTEGER PRIMARY KEY,
product_name VARCHAR2(50)
);
-- Datos de prueba
INSERT INTO T_PRODUCT (id, product_name) VALUES (101, 'Producto A');
INSERT INTO T_PRODUCT (id, product_name) VALUES (102, 'Producto B');
INSERT INTO T_ORDER (id) VALUES (201);
INSERT INTO T_ORDER_ITEM (id, order_id, product_id, quantity) VALUES (301, 201, 101, 10);
INSERT INTO T_ORDER_ITEM (id, order_id, product_id, quantity) VALUES (302, 201, 102, 2);
-- Registro con product_id nulo
INSERT INTO T_ORDER_ITEM (id, order_id, product_id, quantity) VALUES (303, 201, NULL, 5);
-- Registro con product_id inexistente
INSERT INTO T_ORDER_ITEM (id, order_id, product_id, quantity) VALUES (304, 201, 999, 8);
Definición de entidades
Entidad Order:
@Entity
@DynamicInsert
@DynamicUpdate
@Table(name = "T_ORDER")
public class Order implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "order")
@OrderBy("id ASC")
private List<OrderItem> items = new ArrayList<>();
// getters y setters
}
Entidad OrderItem:
@Entity
@DynamicInsert
@DynamicUpdate
@Table(name = "T_ORDER_ITEM")
public class OrderItem implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
// Esta es la configuración problemática
@ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "QUANTITY")
private int quantity;
// getters y setters
}
Entidad Product:
@Entity
@DynamicInsert
@DynamicUpdate
@Table(name = "T_PRODUCT")
public class Product implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(name = "PRODUCT_NAME")
private String name;
// getters y setters
}
Caso de prueba
public class DemoApp {
private static final SessionFactory factory = HibernateHelper.getSessionFactory();
public static void main(String[] args) {
Long orderId = 201L;
Session session = factory.getCurrentSession();
session.beginTransaction();
Order order = session.get(Order.class, orderId);
// Se esperan 4 líneas, pero el resultado es 2
System.out.println("Items recuperados: " + order.getItems().size());
session.getTransaction().commit();
}
}
Clase utilitaria HibernateHelper:
public class HibernateHelper {
private static final SessionFactory SESSION_FACTORY = initFactory();
private static SessionFactory initFactory() {
try {
return new Configuration().configure().buildSessionFactory();
} catch (Throwable ex) {
System.err.println("No se pudo inicializar SessionFactory: " + ex);
throw new ExceptionInInitializerError(ex);
}
}
public static SessionFactory getSessionFactory() {
return SESSION_FACTORY;
}
public static void close() {
SESSION_FACTORY.close();
}
}
Aálisis y solución
Con FetchType.EAGER y nullable = false, Hibernate genera la siguiente consulta:
SELECT
items0_.order_id AS oid_col,
items0_.id AS iid_col,
items0_.product_id AS pid_col,
items0_.QUANTITY AS qty_col,
prod1_.id AS prod_id,
prod1_.PRODUCT_NAME AS prod_name
FROM
T_ORDER_ITEM items0_
INNER JOIN
T_PRODUCT prod1_
ON items0_.product_id = prod1_.id
WHERE
items0_.order_id = ?
ORDER BY
items0_.id
El INNER JOIN elimina las líneas cuyo product_id es nulo o no existe en T_PRODUCT. Al cambiar la configuración a FetchType.LAZY, Hibernate genera una consulta simple sin join:
SELECT
items0_.order_id AS oid_col,
items0_.id AS iid_col,
items0_.product_id AS pid_col,
items0_.QUANTITY AS qty_col
FROM
T_ORDER_ITEM items0_
WHERE
items0_.order_id = ?
ORDER BY
items0_.id
De este modo, los registros con product_id nulo o huérfano no se excluyen del resultado.
Comportamiento de nullalbe con EAGER
Si se mantiene FetchType.EAGER pero se establece nullable = true en @JoinColumn, Hibernate usa LEFT OUTER JOIN en lugar de INNER JOIN, lo que preserva los registros incluso cuando la columna de unión contiene valores nulos:
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = true)
private Product product;
Habilitar el registro de SQL para diagnóstico
Para identificar este tipo de problemas, es esencial visualizar las consultas generadas por Hibernate:
<!-- Mostrar SQL en consola -->
<property name="show_sql">true</property>
<!-- Formatear SQL para legibilidad -->
<property name="format_sql">true</property>
Esto permite detectar joins inesperados y comprender cómo la configuración de FetchType y nullable afecta las consultas resultantes.