1. Introducción
Imaginad que vuestra base de datos es un almacén, y las consultas son empleados que recorren el almacén contando cajas. Una regla estática como @SQLRestriction("deleted = false") es un cartel en la puerta que dice «Solo entrada a la sala nº1». Es para siempre. En cambio, @Filter es más bien un «pase entregado en la entrada»: hoy tienes pase para la sala nº1, y mañana para la sala «Archivo». El mismo empleado, la misma ruta, pero acceso diferente.
En un dominio real de backoffice esto surge muy rápido. Para el catálogo de productos y categorías suele haber una lista «normal», donde los registros eliminados/ocultos no deben aparecer, y un escenario «de administración» como «mostrar productos eliminados» para poder restaurar una posición, comprobar el historial de cambios o simplemente entender por qué un SKU está de repente «ocupado». Y aquí comienza el dolor clásico: si resuelves el problema de forma ingenua, cada consulta termina con un where deleted = false, cada repositorio tiene su propia versión de la «condición correcta», y en cada servicio existe la posibilidad de olvidar una línea y mostrar accidentalmente al usuario el «cementerio de datos» (romántico, pero normalmente no según los requisitos).
La restricción estática funciona perfectamente mientras la regla es la misma para todas las lecturas. Pero en cuanto la lista normal y el modo archivo deben ver conjuntos diferentes de filas, la tarea sigue siendo la misma — gestionar la visibilidad del lado de lectura — y la solución cambia: la condición no debe estar pegada para siempre al mapeo, sino activarse y desactivarse según el escenario. remove() sigue sin cambiar eso: los filtros solo se encargan de la lectura.
El filtro dinámico en Hibernate resuelve este problema de otra manera: la consulta puede mantenerse corta, y la regla de visibilidad se activa a nivel de la Session actual, es decir, a nivel de la unit of work — dentro de una misma transacción. Como resultado, el «modo archivo» y el «modo lista normal» no se diferencian por la cadena JPQL, sino porque al principio del escenario activaste el filtro con el parámetro necesario.
2. Filtro de Hibernate: declaración y activación
@FilterDef y @Filter
Con los filtros de Hibernate es importante no confundir dos capas: «declarar que el filtro existe» y «acoplar el filtro a una entidad o colección». En código esto se expresa con el par de anotaciones @FilterDef y @Filter. Es como un enchufe: una cosa dice «en la pared hay electricidad», la otra dice «aquí puedes conectar un hervidor».
@FilterDef declara el filtro: le da un nombre y describe los parámetros. Los parámetros son lo que establecerás en tiempo de ejecución mediante enableFilter(...).setParameter(...). @Filter se aplica a una entidad (o colección) concreta y contiene una condición SQL que Hibernate añadirá a las consultas cuando el filtro esté activado.
Nótense dos cosas que a menudo sorprenden a los principiantes (e incluso a veces a desarrolladores adultos en la fase de «por qué no funciona»). En primer lugar, la condición en @Filter es un fragmento SQL, no JPQL, es decir, piensas en columnas (deleted, status, tenant_id) y no en campos de la clase Java (deleted, status, tenantId) — es parecido, pero lo «parecido» aquí es peligroso. En segundo lugar, por defecto el filtro está desactivado en una nueva sesión, y hasta que no lo actives no afecta en absoluto a las consultas.
A continuación, un ejemplo máximo «didáctico» de filtro para Product, donde queremos poder obtener ya sea productos activos o eliminados (por ejemplo, para la pantalla «Archivo»).
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
// Declaramos el filtro: nombre + lista de parámetros que estableceremos en tiempo de ejecución
@FilterDef(
name = "productVisibility",
parameters = @ParamDef(name = "deleted", type = Boolean.class) // Parámetro del filtro (indicador booleano)
)
// Vinculamos el filtro a la entidad: es un fragmento SQL que se mezclará en las consultas a la tabla de producto
@Filter(name = "productVisibility", condition = "deleted = :deleted")
class Product {
// campos y anotaciones JPA los omitimos para no distraer
}
@FilterDef normalmente se coloca junto al tipo mapeado o en package-info.java. Aquí está justo encima de Product para que el nombre del filtro, el parámetro y la condición SQL se lean en un mismo lugar.
Aquí el filtro se llama productVisibility, y tiene un solo parámetro — deleted. Cuando el filtro está activado, Hibernate añadirá a la SQL la condición deleted = ? (y tomará el valor del parámetro), pero solo para las consultas que carguen Product a través de Hibernate.
Si te sorprendes pensando «¿y por qué no hacer directamente deleted = false y no molestarse?» — esa es precisamente la frontera entre la restricción estática y la dinámica. Hacemos un filtro cuando queremos que la misma consulta funcione de manera diferente en distintos escenarios, sin copiar y pegar código.
enableFilter(...) y parámetros
Ahora lo más importante: el filtro en sí mismo es solo una descripción. Para que empiece a influir realmente en la SQL, hay que activarlo en la sesión actual de Hibernate. Y en nuestra aplicación Spring Boot, esa sesión normalmente vive dentro de una transacción creada mediante @Transactional. Es decir, activas el filtro en un método de servicio, y solo actúa dentro de esa unit of work (lo que es ideal para escenarios como «muestra de manera diferente justo ahora»).
En JPA no hay filtros directamente: es una característica de Hibernate. Por eso hacemos un «truco legal»: tomamos el EntityManager y «desenvolvemos» (unwrap()) de él la Session. Después podemos llamar a enableFilter(...) y establecer los parámetros.
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public void enableVisibleProducts(EntityManager em) {
// Obtenemos la Session de Hibernate desde el EntityManager de JPA (de lo contrario enableFilter() no está disponible)
Session session = em.unwrap(Session.class);
// Activamos el filtro en la sesión actual y establecemos el parámetro inmediatamente (importante hacerlo seguido)
session.enableFilter("productVisibility")
.setParameter("deleted", false);
}
El sentido de este esqueleto es simple: en cuanto activas el filtro, todas las consultas posteriores que en esta transacción carguen Product comenzarán a recibir implícitamente una condición adicional. Esto se aplica no solo a tu createQuery("select p from Product p"), sino también a las consultas que Hibernate hace «por el camino» — por ejemplo, al inicializar perezosamente colecciones o al cargar entidades relacionadas, si también tienen filtro.
Y sí, el filtro en este sentido es stateful. No es una «opción de consulta», sino un estado de la sesión. Por eso es mejor activar el filtro lo más cerca posible del inicio del escenario y en el lugar más explícito del código, para no tener que buscar después por el proyecto «quién me puso un WHERE invisible».
Si no se establece el parámetro del filtro, Hibernate no podrá construir una SQL correcta y obtendrás un error ya en tiempo de ejecución de la consulta. Es normal: el filtro no es una pegatina mágica, realmente participa en la generación de la SQL.
3. Dónde funciona el filtro y dónde no
Cuando los filtros llegan por primera vez a manos de un desarrollador, surge la tentación de pensar en ellos como un «WHERE global para toda la aplicación». Y aquí es útil hacer una pausa y entender el límite real: el filtro afecta a lo que pasa a través de Hibernate ORM, pero no es una «ley física» de la base de datos. La base de datos no sabe en absoluto que tenías un filtro activado — simplemente ejecuta la SQL que le enviaron.
En la práctica, esto significa que el filtro funciona perfectamente para consultas JPQL/HQL (porque Hibernate las convierte a SQL por sí mismo), así como para la inicialización de colecciones perezosas y la carga normal de entidades. Pero en cuanto pasas a createNativeQuery(...) o a JDBC directamente, el filtro deja de existir — tú mismo escribiste la SQL, tú mismo eres responsable de todos los predicados.
Para que sea más fácil tenerlo en la cabeza, aquí hay una pequeña tabla de referencia. No cubre «todos los casos de la vida», pero para un proyecto de aprendizaje y código backend diario cubre el 90% de las dudas.
| Acción en el código | Pasa por la generación SQL de Hibernate | El filtro puede afectar |
|---|---|---|
| entityManager.createQuery("select p from Product p", Product.class) | sí | sí |
| productRepository.findAll() | sí | sí |
| product.getAssignments() en LAZY (inicialización de colección) | sí | sí |
| entityManager.createNativeQuery("select * from product") | no | no |
| JdbcTemplate.query(...) | no | no |
Para un texto más profundo, es mejor no construir una demostración de filtros alrededor de find(): allí es demasiado fácil confundirse por el estado del contexto de persistencia y el runtime concreto. JPQL/HQL y la inicialización perezosa de colecciones muestran la influencia del filtro de manera mucho más transparente según la SQL generada.
Por separado, vale la pena mencionar el peligro «suave»: el filtro puede afectar al lazy loading en medio de un método, y a veces parece que «los datos desaparecieron». En realidad no desapareció nada: activaste el modo de visibilidad y luego te sorprendiste de que ese modo se aplicó honestamente. Precisamente por eso el filtro debe percibirse como parte del escenario, no como un hook accidental «en algún lugar cerca del repositorio».
4. @FilterJoinTable para join table
Pasamos al lugar donde la gente suele empezar a entristecerse un poco: la tabla de relación (join table). Mentalmente se ve así: «tengo un Product y tengo una Category, hay una relación entre ellos, y quiero filtrar la relación, no las categorías en sí». Si la columna que controla la visibilidad está precisamente en la tabla de relación, entonces el filtro en la entidad Category no ayudará, porque filtra las filas de la tabla category, no las filas de la tabla product_category.
Y aquí aparece @FilterJoinTable. Es parecido a un @Filter normal, solo que se aplica no a la entidad hija, sino a la join table que Hibernate utiliza al cargar la colección. En términos de sentido común, @FilterJoinTable responde a la pregunta: «qué relaciones considero visibles», no «qué categorías considero visibles».
Para visualizarlo, es útil dibujar un esquema. Incluso una imagen simple salva al cerebro de confusiones:
flowchart LR
%% product está relacionado con category a través de la tabla de enlace product_category
P[product] --- PC[product_category]
PC --- C[category]
%% Las banderas de visibilidad/estado de la relación están precisamente en la join table
PC -. "deleted / assigned_at / ... vive aquí" .- PC
Si en product_category hay una columna deleted (o, digamos, active), entonces hay que filtrar precisamente esa.
Ejemplo didáctico (se acerca más a un ManyToMany legacy/ingenuo, pero el mecanismo es el mismo — lo importante es la idea). En código real, @FilterDef para estos casos suele declararse junto al tipo mapeado o en package-info.java. A continuación separo deliberadamente solo la declaración, para no inflar el ejemplo con un ManyToMany.
Primero declaramos el filtro:
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
// Fragmento separado solo para la declaración del filter-def.
// En un proyecto real, @FilterDef suele colocarse junto al tipo mapeado o en package-info.java.
@FilterDef(
name = "categoryLinkVisibility",
parameters = @ParamDef(name = "deleted", type = Boolean.class) // Filtramos las filas de la join table por la bandera deleted
)
class FiltersConfig {
}
Luego lo vinculamos a la join table en la colección:
import org.hibernate.annotations.FilterJoinTable;
// Este filtro se aplicará a la tabla de relación al cargar la colección categories
@FilterJoinTable(
name = "categoryLinkVisibility",
condition = "deleted = :deleted" // Fragmento SQL para la join table
)
private java.util.List<Category> categories;
En código real, esto estará en el campo de colección dentro de la entidad donde se definen @ManyToMany y @JoinTable(...). Muestro el fragmento deliberadamente, porque una sábana completa de anotaciones en una lección didáctica suele hacer dos cosas: asustar al principiante y enmascarar el significado.
En nuestro curso (y en el proyecto Commerce Persistence Lab) ya «hemos crecido» hasta una entidad de enlace ProductCategoryAssignment, es decir, nuestra relación no se esconde en una join table, sino que se convierte en una entidad de pleno derecho. Esto, de hecho, simplifica la vida: en lugar de @FilterJoinTable, puedes filtrar la propia entidad-tabla de assignments con un @Filter normal. Pero conocer @FilterJoinTable sigue siendo útil, porque en proyectos reales aparecen ManyToMany y join tables, y además los filtros de join table se pueden aplicar a algunas «tablas de colección» (cuando no tienes una entidad, sino una tabla para una colección de valores).
5. Filtros y link entity en el proyecto
Ahora aterricemos en nuestro proyecto para que no quede todo como «anotaciones en el vacío». En Commerce Persistence Lab la relación entre producto y categoría no vive en un ManyToMany ingenuo, sino en una entidad separada ProductCategoryAssignment. Esto significa que las reglas de visibilidad de la relación se expresan de manera más natural como reglas de visibilidad de la propia entidad assignment. Y esto combina perfectamente con @Filter, porque el filtro sobre una entidad es la opción más directa y legible.
En nuestro baseline, ProductCategoryAssignment también vive con deleted, y la reasignación de una categoría se trata como la restauración de la misma fila. Por lo tanto, precisamente en esta entidad es conveniente mostrar la visibilidad del lado de lectura conmutable: no una nueva capa del lado de escritura, sino un modo de lectura escénico.
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
// El filtro se declara y se cuelga inmediatamente en la entidad de enlace (link entity)
@FilterDef(
name = "assignmentVisibility",
parameters = @ParamDef(name = "deleted", type = Boolean.class) // El parámetro se establece al activar el filtro en la Session
)
@Filter(name = "assignmentVisibility", condition = "deleted = :deleted") // Fragmento SQL para la tabla assignments
class ProductCategoryAssignment {
}
Es la misma entidad assignment del catálogo. La diferencia está solo en la mecánica: la versión estática podría haberse fijado con una restricción, mientras que aquí la visibilidad cambia mediante un parámetro en la Session.
Y luego todo funciona como en las secciones anteriores: en el escenario de servicio deseado, activas assignmentVisibility y estableces el parámetro. Cuando Hibernate cargue los assignments (por ejemplo, inicializando perezosamente product.getAssignments()), añadirá el predicado a la SQL.
¿Por qué es conveniente en un dominio didáctico? Porque a menudo queremos el mismo «esqueleto de lectura» (cargar producto, mostrar categorías) en dos modos: «catálogo normal» y «modo mantenimiento/limpieza». Y si la relación está modelada como una entidad separada, filtrarla es más sencillo y honesto: filtras las filas de la tabla assignment, no intentas «influir mágicamente» en la join table.
Otro bono agradable: cuando la relación se ha convertido en entidad, ya la piensas como datos. Tiene su propio id, puede tener campos como assignedAt, sortOrder, posiblemente aparecerá deletedAt. Y los filtros se convierten no en «hacks de ORM», sino en una forma normal de decir: «en este escenario solo necesito las relaciones activas».
6. Un mismo JPQL, diferentes filtros
La forma más práctica de creer en los filtros es ver cómo un mismo JPQL se convierte en SQL diferente según el filtro activado. Es el momento en que de la cabeza desaparece el hábito de «entonces la condición debe estar en la consulta», y aparece un pensamiento más maduro: «la condición puede ser parte del contexto de sesión del escenario».
Supongamos que tenemos la misma consulta de lectura de productos:
import jakarta.persistence.EntityManager;
public void loadProducts(EntityManager em) {
// El mismo JPQL: el filtro será "mezclado" por Hibernate de forma transparente, si está activado en la Session
em.createQuery("select p from Product p order by p.name", Product.class)
.getResultList();
}
Si el filtro productVisibility está activado con el parámetro deleted=false, entonces la SQL se verá lógicamente así (muy parecido a lo que verías en un trace SQL):
select p.id, p.sku, p.name, p.deleted
from product p
where p.deleted = ?
order by p.name;
-- bind => [false] -- el valor proviene de session.enableFilter(...).setParameter(...)
Si en otro escenario (por ejemplo, «archivo») activaste el filtro con deleted=true, entonces Hibernate enviará una consulta casi igual, pero con otro valor de bind:
select p.id, p.sku, p.name, p.deleted
from product p
where p.deleted = ?
order by p.name;
-- bind => [true] -- la misma SQL, diferente contexto de visibilidad (parámetro del filtro)
Y esto es el valor principal del filtro dinámico. No multiplicas dos versiones de la consulta, no duplicas el ordenamiento, no olvidas la paginación en una de las variantes, no creas «métodos casi iguales» en el repositorio. Cambias el contexto de visibilidad y obtienes datos diferentes a partir de la misma «forma de lectura».
Pero junto con el valor viene la responsabilidad: como la visibilidad pasó a ser parte del contexto, debes activar el filtro de forma predecible, a nivel de servicio, y tener en cuenta que dentro de una misma transacción afecta a todo lo que se cargue a través de Hibernate.
7. Errores típicos al trabajar con @Filter
Los filtros a menudo parecen «magia que ahorra código», y precisamente por eso pueden convertirse en «magia que te ahorra el sueño». La buena noticia es que la mayoría de los errores aquí son muy típicos, y si conoces estos rastrillos de antemano, pisarás al menos con cuidado, en zapatillas suaves, y no descalzo sobre LEGO.
Error nº1: el filtro se declaró pero nunca se activó.
Es un clásico del género. En el código hay bonitos @FilterDef y @Filter, esperas que findAll() de repente deje de mostrar registros eliminados, pero en realidad no cambia nada. La razón es simple: el filtro por defecto está desactivado en una nueva Session. No se cura con «una anotación más», sino con disciplina: la activación del filtro es parte del escenario, por lo tanto debe estar explícitamente en el servicio y dentro de una transacción.
Error nº2: el filtro se activó pero se olvidó establecer el parámetro.
Hibernate no puede «adivinar» lo que querías decir, y eso es bueno. Pero como resultado, la primera consulta fallará con un error sobre el parámetro del filtro no establecido. Normalmente ocurre cuando la activación se extrajo a un método helper general, y el parámetro se decidió establecer «en algún lugar después». Ese «después» llega inesperadamente, ya en producción. La regla más simple: si el filtro es parametrizable, actívalo y establece los parámetros en el mismo lugar, seguido, sin pausa para filosofar.
Error nº3: el filtro se activó y luego te sorprendiste de que «los datos desaparecieron» en otro lugar del mismo método.
El filtro vive en la Session, no en la consulta. Por eso, si activaste el filtro, hiciste una consulta y luego llamaste a otro repositorio o inicializaste perezosamente una colección, el filtro sigue actuando. Esto no es un bug de Hibernate — es que activaste un modo de visibilidad y olvidaste que es común para la unit of work actual. Normalmente se cura así: el filtro se activa al principio del escenario, y el escenario se mantiene compacto y claro. Si quieres dos modos de visibilidad diferentes en un mismo método, es una señal de alarma de que estás mezclando dos casos de uso en una sola transacción.
Error nº4: intentar usar @Filter donde la regla es siempre la misma.
A veces un desarrollador coloca un filtro, lo activa en cada servicio, siempre con el mismo parámetro, y se enorgullece de la «visibilidad dinámica». En realidad, es simplemente una regla estática que hiciste dinámica y ahora estás obligado a activar manualmente. Si la regla es «ocultar siempre lo eliminado», las restricciones estáticas (@SQLRestriction) o un mecanismo básico de soft delete suelen ser más simples y fiables. El filtro es bueno precisamente cuando realmente quieres conmutar el comportamiento según el escenario.
Error nº5: esperar que los filtros se apliquen a SQL nativo.
Si escribiste createNativeQuery("select * from product"), Hibernate no tiene derecho (ni posibilidad) de meterse en tu texto SQL y «añadir una condición en alguna parte». Por lo tanto, los filtros no funcionan allí. Si el escenario usa SQL nativo, la regla de visibilidad debe expresarse directamente en esa SQL o garantizarse mediante otro mecanismo independiente de la generación ORM.
GO TO FULL VERSION