Con la lección anterior, CicloUrbana sabe guardar, buscar por identificador, contar y paginar. Sabe hacer lo elemental. Pero la red de Ribalta necesita mucho más: las estaciones con hueco libre, las bicicletas con la batería por debajo del umbral, los alquileres de un usuario entre dos fechas, las estaciones más cercanas a una coordenada, el informe de ocupación en una sola consulta. Todo eso son preguntas, y Spring Data ofrece cinco formas distintas de formularlas.
Esta es la lección más práctica del módulo y la que más cambia el día a día. Aquí está el arsenal completo: las consultas derivadas del nombre del método —el mecanismo que más sorprende a quien llega a Spring Data—, @Query con JPQL, las consultas nativas de PostgreSQL, las modificaciones masivas con @Modifying, las proyecciones que evitan cargar entidades enteras, @EntityGraph para resolver el N+1 de 04-04 y las Specification para filtros dinámicos. Y, transversal a todo, el criterio para elegir: cada mecanismo tiene un punto en el que deja de ser la herramienta adecuada.
Contenido
- Consultas derivadas del nombre del método
- Tabla exhaustiva de palabras clave
- Propiedades anidadas y ambigüedades
- Cuándo abandonar el nombre del método
@Querycon JPQL- Proyecciones a DTO en la consulta
JOIN FETCHy la resolución del N+1- Consultas nativas
@Modifying:UPDATEyDELETE- Proyecciones por interfaz y por
record @EntityGraphSpecificationy la API Criteria@NamedQuery,StreamableyStream- Verificar el SQL realmente ejecutado
- Errores Comunes y Consejos
- Ejercicios
- Consultas derivadas del nombre del método
El mecanismo es simple de enunciar y sorprendente la primera vez: declaras un método con un nombre que describe la consulta y Spring Data la genera.
public interface BicicletaRepositorio extends JpaRepository<Bicicleta, Long> {
Optional<Bicicleta> findByMatricula(String matricula);
List<Bicicleta> findByEstado(EstadoBicicleta estado);
List<Bicicleta> findByEstadoAndNivelBateriaGreaterThanEqual(
EstadoBicicleta estado, int nivelMinimo);
long countByEstacionId(Long estacionId);
boolean existsByMatricula(String matricula);
}Ninguno tiene implementación y los cinco funcionan. En el arranque, PartTreeJpaQuery analiza cada nombre y construye la consulta.
Cómo se analiza el nombre. Se divide en dos partes: el sujeto (findByEstado... → find) indica qué se devuelve —find, read, get, query, search son equivalentes; además count, exists, delete, con Distinct y limitadores como Top10 o First—, y el predicado, que empieza en By y describe el filtro con nombres de propiedad, operadores y conectores.
El análisis es estricto. findByNivelBateria funciona porque Bicicleta tiene un campo nivelBateria; findByBateria falla al arrancar:
org.springframework.data.mapping.PropertyReferenceException:
No property 'bateria' found for type 'Bicicleta'. Did you mean 'nivelBateria'?Es una gran ventaja: el error aparece al arrancar, no en la primera petición. Renombrar un campo de la entidad rompe el arranque y obliga a corregir los métodos que lo usaban: la validación temprana que las cadenas de SQL nunca dan.
- Tabla exhaustiva de palabras clave
| Palabra clave | Ejemplo en CicloUrbana | JPQL generado (fragmento) |
|---|---|---|
findBy |
findByNombre(String n) |
where e.nombre = ?1 |
readBy/getBy/queryBy |
Sinónimos de findBy |
Igual |
countBy |
countByEstadoAndEstacionId(...) |
select count(e) where ... |
existsBy |
existsByMatricula(String m) |
select count(e) > 0 where ... |
deleteBy/removeBy |
deleteByEstadoAndFinIsNotNull(...) |
delete from ... where ... |
And |
findByEstadoAndActivaTrue(...) |
where a = ?1 and b = true |
Or |
findByEstadoOrNivelBateriaLessThan(...) |
where a = ?1 or b < ?2 |
Between |
findByInicioBetween(Instant d, Instant h) |
where e.inicio between ?1 and ?2 |
LessThan / LessThanEqual |
findByNivelBateriaLessThan(int n) |
where e.nivelBateria < ?1 |
GreaterThan / GreaterThanEqual |
findByCapacidadGreaterThanEqual(int c) |
where e.capacidad >= ?1 |
After / Before |
findByInicioAfter(Instant i) |
where e.inicio > ?1 |
Like / NotLike |
findByNombreLike(String p) |
where e.nombre like ?1 |
Containing |
findByNombreContaining(String t) |
where e.nombre like %?1% |
StartingWith / EndingWith |
findByMatriculaStartingWith("RB-") |
where e.matricula like ?1% |
In / NotIn |
findByEstadoIn(List<EstadoBicicleta> l) |
where e.estado in ?1 |
IsNull / IsNotNull |
findByFinIsNull() |
where e.fin is null |
True / False |
findByActivaTrue() |
where e.activa = true |
IgnoreCase |
findByNombreIgnoreCase(String n) |
where upper(e.nombre) = upper(?1) |
OrderBy...Asc/Desc |
findByActivaTrueOrderByNombreAsc() |
order by e.nombre asc |
Top/First |
findTop5ByOrderByInicioDesc() |
limit 5 |
Distinct |
findDistinctByEstacionId(Long id) |
select distinct e |
Not |
findByEstadoNot(EstadoBicicleta e) |
where e.estado <> ?1 |
Los repositorios de CicloUrbana con consultas útiles reales:
public interface EstacionRepositorio extends JpaRepository<Estacion, Long> {
Optional<Estacion> findByNombreIgnoreCase(String nombre);
boolean existsByNombre(String nombre);
List<Estacion> findByActivaTrueOrderByNombreAsc();
List<Estacion> findByCapacidadGreaterThanEqual(int capacidadMinima);
List<Estacion> findByNombreContainingIgnoreCase(String texto);
Page<Estacion> findByActivaTrue(Pageable pageable);
}
public interface AlquilerRepositorio extends JpaRepository<Alquiler, Long> {
List<Alquiler> findByUsuarioIdAndFinIsNull(Long usuarioId); // en curso
Page<Alquiler> findByUsuarioIdOrderByInicioDesc(Long id, Pageable p);
List<Alquiler> findByInicioBetween(Instant desde, Instant hasta);
long countByBicicletaIdAndFinIsNotNull(Long bicicletaId);
List<Alquiler> findTop10ByOrderByImporteDesc(); // más caros
}Fíjate en findByUsuarioIdAndFinIsNull: es la consulta que responde «¿tiene este usuario un alquiler en curso?», la regla de negocio central de CicloUrbana, y cabe en el nombre de un método sin escribir SQL ni JPQL. Un detalle práctico: el orden de los parámetros debe coincidir con el orden en que aparecen en el nombre; invertirlos no compila si los tipos difieren, pero con dos parámetros del mismo tipo el error pasa a ejecución y produce resultados incorrectos en silencio. Buen motivo para no encadenar demasiadas condiciones.
- Propiedades anidadas y ambigüedades
Puedes navegar a propiedades de entidades relacionadas:
List<Bicicleta> findByEstacionNombre(String nombre); // bicicletas de la estación "X"
List<Alquiler> findByUsuarioCorreo(String correo); // alquileres de ese correoSpring Data genera un JOIN automáticamente:
El problema de la ambigüedad. El analizador resuelve findByEstacionNombre de forma voraz: primero busca una propiedad estacionNombre en Bicicleta y, si no la encuentra, parte por la última mayúscula y busca estacion.nombre. Si existieran ambas ganaría la primera, y la consulta sería otra de la que pretendías sin ningún error. Para desambiguar se usa el guion bajo: findByEstacion_Nombre(String nombre) es inequívoco. Es feo y rompe la convención de nombres de Java, pero es explícito.
Cuidado con la navegación profunda: findByBicicletaEstacionUbicacionLatitud(...) genera tres JOIN encadenados en un nombre ilegible. Cuando llegues ahí, pasa a @Query.
- Cuándo abandonar el nombre del método
Las consultas derivadas son excelentes hasta cierto punto, y ese punto se reconoce sin ambigüedad:
| Señal | Ejemplo |
|---|---|
| El nombre supera unos 60 caracteres | findByEstadoAndNivelBateriaLessThanAndEstacionActivaTrueOrderByNivelBateriaAsc |
| Hay más de tres o cuatro condiciones | Cualquiera con tres And y un Or |
Mezcla And y Or |
La precedencia no es evidente al leerlo |
| Necesita agregaciones | count, sum, avg sobre columnas |
| Necesita subconsultas | «estaciones sin ninguna bicicleta disponible» |
Necesita JOIN FETCH |
Resolver el N+1 |
| El filtro es opcional | Filtros que pueden venir o no |
Las alternativas, por orden de preferencia: @Query con JPQL para consultas complejas pero portables; consulta nativa cuando se necesita SQL específico de PostgreSQL; Specification para filtros dinámicos y combinables; y un repositorio propio (Impl) para lógica que no cabe en una sola consulta.
@Query con JPQL
@Query con JPQLJPQL (Jakarta Persistence Query Language) es SQL sobre el modelo de objetos: consulta entidades y sus propiedades, no tablas y columnas.
@Query("""
select e from Estacion e
where e.activa = true
and e.capacidad >= :capacidadMinima
order by e.nombre
""")
List<Estacion> buscarActivasConCapacidad(@Param("capacidadMinima") int capacidadMinima);Fíjate en Estacion con mayúscula y e.capacidad: son el nombre de la clase Java y el de su campo, no estaciones ni la columna. Eso es lo que hace JPQL portable entre motores y verificable contra el modelo. Y los bloques de texto de Java (""") son la forma correcta de escribir consultas de varias líneas, sin concatenación ni espacios perdidos.
Parámetros posicionales frente a nombrados:
// Posicionales: frágiles. Reordenar los argumentos rompe la consulta en silencio
@Query("select e from Estacion e where e.capacidad >= ?1 and e.activa = ?2")
List<Estacion> buscar(int capacidad, boolean activa);
// Nombrados: la forma recomendada
@Query("select e from Estacion e where e.capacidad >= :capacidad and e.activa = :activa")
List<Estacion> buscar(@Param("capacidad") int capacidad, @Param("activa") boolean activa);Usa siempre parámetros nombrados con @Param. Los posicionales dependen del orden de los argumentos y no dan ninguna pista al leerlos. Y una advertencia heredada del mundo JDBC: nunca concatenes valores en el texto de la consulta, porque eso es una inyección SQL. Los parámetros de JPA viajan en sentencias preparadas, con los valores separados del texto: esa es su protección.
Consultas paginadas con @Query y su countQuery:
@Query(value = "select a from Alquiler a where a.usuario.id = :usuarioId "
+ "and a.inicio between :desde and :hasta",
countQuery = "select count(a) from Alquiler a where a.usuario.id = :usuarioId "
+ "and a.inicio between :desde and :hasta")
Page<Alquiler> buscarPorUsuarioYPeriodo(@Param("usuarioId") Long usuarioId,
@Param("desde") Instant desde,
@Param("hasta") Instant hasta,
Pageable pageable);Spring Data puede derivar el countQuery automáticamente, pero con consultas complejas —sobre todo con JOIN FETCH o DISTINCT— la derivación falla o genera un recuento mucho más caro de lo necesario. Declararlo es la opción segura.
- Proyecciones a DTO en la consulta
Una de las técnicas de más impacto de toda la lección: traer solo las columnas necesarias y construir directamente el DTO, sin pasar por entidades gestionadas.
package com.ciclourbana.estaciones.dto;
public record OcupacionEstacion(Long estacionId, String nombre, int capacidad,
long bicicletasDisponibles) { }@Query("""
select new com.ciclourbana.estaciones.dto.OcupacionEstacion(
e.id, e.nombre, e.capacidad, count(b.id))
from Estacion e
left join e.bicicletas b
on b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.DISPONIBLE
where e.activa = true
group by e.id, e.nombre, e.capacidad
order by e.nombre
""")
List<OcupacionEstacion> consultarOcupacion();Requisitos del select new: nombre de clase completamente cualificado y un constructor cuyos tipos coincidan exactamente con los de las expresiones. Un record lo cumple de forma natural. Compara con la alternativa de cargar entidades:
| Cargar entidades y contar en Java | select new con count |
|
|---|---|---|
| Consultas | 1 + N (o 1 con JOIN FETCH) |
1 |
| Datos transferidos | Todas las columnas de estaciones y bicicletas | 4 columnas por estación |
| Entidades en el contexto | Miles | Ninguna |
Riesgo de LazyInitializationException |
Sí | No |
| Reutilizable como respuesta | Requiere mapeo | Es ya el DTO |
Es la solución que elegimos en el ejercicio 2 de 04-04, ahora escrita del todo. La regla: si el endpoint no va a modificar nada, plantéate si necesita entidades en absoluto.
JOIN FETCH y la resolución del N+1
JOIN FETCH y la resolución del N+1Cuando sí necesitas entidades con sus relaciones cargadas, JOIN FETCH las trae en una sola consulta.
@Query("""
select distinct e from Estacion e
left join fetch e.bicicletas
where e.id = :id
""")
Optional<Estacion> buscarConBicicletas(@Param("id") Long id);join fetch frente a join a secas. Un join normal sirve para filtrar: puedes poner condiciones sobre la entidad unida, pero no se carga, así que acceder a ella después dispara consultas perezosas. join fetch filtra y carga. La distinción es sutil y crítica.
Por qué distinct. Con un JOIN a una colección, la base de datos devuelve una fila por bicicleta, es decir, la estación repetida N veces; sin distinct, la lista contendría duplicados. En Hibernate 6 se aplica en memoria sin añadirlo al SQL, así que no penaliza. Puedes encadenar varios niveles:
@Query("""
select distinct a from Alquiler a
join fetch a.usuario
join fetch a.bicicleta b
join fetch b.estacion
where a.fin is null
""")
List<Alquiler> buscarEnCursoConDetalle();Cuatro entidades en una sola consulta, en lugar de 1 + 3N.
Los dos límites de JOIN FETCH. El primero: no se puede paginar con colecciones; ya lo vimos en 04-04, Hibernate avisa con HHH90003004 y pagina en memoria, así que con colecciones y paginación hay que usar @BatchSize o dos consultas (una de ids paginados y otra con where id in). El segundo: un solo fetch de colección por consulta, porque hacer join fetch de dos colecciones distintas genera un producto cartesiano —30 bicicletas × 20 incidencias = 600 filas para una estación—; Hibernate 6 lo permite, pero casi siempre es un error.
- Consultas nativas
Cuando JPQL no alcanza, nativeQuery = true ejecuta SQL directo del motor.
@Query(value = """
SELECT e.id, e.nombre, e.direccion, e.capacidad,
e.latitud, e.longitud,
(6371 * acos(
cos(radians(:latitud)) * cos(radians(e.latitud)) *
cos(radians(e.longitud) - radians(:longitud)) +
sin(radians(:latitud)) * sin(radians(e.latitud))
)) AS distancia_km
FROM estaciones e
WHERE e.activa = true
ORDER BY distancia_km ASC
LIMIT :limite
""", nativeQuery = true)
List<EstacionCercanaProyeccion> buscarMasCercanas(@Param("latitud") double latitud,
@Param("longitud") double longitud,
@Param("limite") int limite);Esa expresión es la fórmula del semiverseno (haversine), que calcula la distancia sobre la superficie terrestre entre dos coordenadas. JPQL no tiene funciones trigonométricas, así que no hay alternativa portable. La proyección que recoge el resultado (apartado 10):
public interface EstacionCercanaProyeccion {
Long getId(); String getNombre(); String getDireccion();
Integer getCapacidad();
Double getDistanciaKm(); // mapea la columna distancia_km
}Cuándo se justifica una consulta nativa: funciones específicas del motor (geoespaciales, JSONB, texto completo); funciones de ventana (ROW_NUMBER, LAG, RANK), que JPQL no soporta; CTE (WITH ... AS) y consultas recursivas; optimizaciones concretas como el ON CONFLICT de PostgreSQL; y operaciones masivas donde el rendimiento manda.
Sus riesgos, que hay que asumir conscientemente:
| Riesgo | Consecuencia |
|---|---|
| Portabilidad | El SQL de PostgreSQL no funciona en H2, aunque MODE=PostgreSQL ayuda |
| Nombres físicos | Renombrar una columna en la entidad no actualiza la consulta |
| Sin validación | Los errores aparecen en ejecución, no al arrancar |
| Fuera del contexto | Devuelve datos crudos; las entidades cargadas no se sincronizan |
| Pruebas | Obligan a probar contra PostgreSQL real (Testcontainers, 06-05) |
El segundo riesgo es el más traicionero: una consulta nativa referencia nivel_bateria, alguien renombra el campo en la entidad y actualiza el esquema, y la consulta compila, arranca y falla en producción. La regla de CicloUrbana: JPQL por defecto, nativo solo cuando JPQL no puede; y cuando uses nativo, cúbrelo con una prueba de integración.
@Modifying: UPDATE y DELETE
@Modifying: UPDATE y DELETEPor defecto, @Query asume una consulta de lectura. Para modificar hace falta @Modifying:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
update Bicicleta b
set b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.MANTENIMIENTO
where b.nivelBateria < :umbral
and b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.DISPONIBLE
""")
int marcarParaMantenimientoPorBateria(@Param("umbral") int umbral);Devuelve el número de filas afectadas. Tres cosas son obligatorias o casi:
@Transactional es imprescindible. Sin ella, TransactionRequiredException: Executing an update/delete query. Lo habitual es que la transacción venga del servicio (04-07).
flushAutomatically = true vuelca al UPDATE los cambios pendientes del contexto antes de ejecutar la consulta; sin él, una bicicleta modificada en memoria y aún no volcada no cumpliría el WHERE que debería cumplir.
clearAutomatically = true es el más importante y el peor entendido. Una consulta de modificación se ejecuta directamente en la base de datos, saltándose el contexto de persistencia, así que las entidades ya cargadas quedan con valores obsoletos:
@Transactional
public void ejemploDesincronizacion() {
Bicicleta bici = bicicletaRepositorio.findById(1L).orElseThrow(); // DISPONIBLE
bicicletaRepositorio.marcarParaMantenimientoPorBateria(20); // UPDATE en la BD
bici.getEstado(); // ¡sigue diciendo DISPONIBLE! Viene del contexto, no de la BD
}Peor todavía: si después se modifica bici y se hace commit, el dirty checking escribiría el estado antiguo, deshaciendo la actualización masiva. clearAutomatically = true vacía el contexto tras la consulta, forzando a releer.
graph TD
A["findById(1) -> contexto: bici DISPONIBLE"] --> B["@Modifying UPDATE en la BD"]
B --> C{"clearAutomatically"}
C -->|false| D["El contexto conserva DISPONIBLE<br/>(datos obsoletos)"]
C -->|true| E["Contexto vaciado<br/>la siguiente lectura va a la BD"]
Cuándo usar @Modifying y cuándo no. Es la herramienta correcta para operaciones masivas —marcar cien bicicletas, cerrar los alquileres abandonados de la noche— y no lo es para modificar una entidad concreta: ahí basta cargarla y cambiarla, dejando trabajar al dirty checking. Advertencia final: una consulta de modificación se salta las cascadas, los @EntityListeners y el bloqueo optimista, de modo que modificado_en no se actualiza y version no se incrementa; si eso importa, hazlo explícitamente en la propia consulta.
- Proyecciones por interfaz y por
record
recordUna proyección devuelve un subconjunto de datos en lugar de la entidad completa. Hay tres formas.
Proyección cerrada por interfaz. Declaras una interfaz con métodos getX() que coinciden con propiedades:
public interface EstacionResumen {
Long getId(); String getNombre(); Integer getCapacidad();
}
// y en el repositorio, sin cambiar el nombre del método:
List<EstacionResumen> findByActivaTrue();Spring Data genera un proxy y, lo más importante, restringe el SELECT a las columnas necesarias: select e1_0.id, e1_0.nombre, e1_0.capacidad from estaciones e1_0 where e1_0.activa = true. Con una tabla de treinta columnas, la diferencia de datos transferidos es enorme.
Proyección abierta con @Value y SpEL. Permite calcular:
public interface EstacionEtiquetada {
String getNombre(); Integer getCapacidad();
@Value("#{target.nombre + ' (' + target.capacidad + ' plazas)'}")
String getEtiqueta();
}Tiene un coste que conviene conocer: con @Value, Spring Data carga la entidad completa para evaluar la expresión, perdiendo la optimización del SELECT. Úsala solo cuando necesites el cálculo.
Proyección a record. La más limpia con Java moderno: basta declarar public record EstacionResumen(Long id, String nombre, Integer capacidad) { } y usarlo como tipo de retorno. Spring Data reconoce el record y usa su constructor canónico; los nombres de los componentes deben coincidir con los de las propiedades.
Proyecciones dinámicas. El mismo método puede devolver distintas formas según lo que pidas:
<T> List<T> findByActivaTrue(Class<T> tipo);
// repositorio.findByActivaTrue(Estacion.class) -> entidades completas
// repositorio.findByActivaTrue(EstacionResumen.class) -> proyección resumida| Tipo | SELECT optimizado |
Calcula | Sintaxis |
|---|---|---|---|
| Interfaz cerrada | Sí | No | Métodos getX() |
Interfaz abierta (@Value) |
No | Sí | SpEL |
record |
Sí | No | La más concisa |
select new en @Query |
Sí | Sí (agregaciones) | Nombre cualificado |
@EntityGraph
@EntityGraph@EntityGraph declara, por método, qué asociaciones cargar, sin escribir JPQL:
@EntityGraph(attributePaths = {"bicicletas"})
Optional<Estacion> findById(Long id);
@EntityGraph(attributePaths = {"usuario", "bicicleta", "bicicleta.estacion"})
List<Alquiler> findByFinIsNull(); // carga cuatro entidades en una consultaGenera los mismos LEFT JOIN FETCH que escribirías a mano y funciona tanto sobre consultas derivadas como sobre @Query.
JOIN FETCH |
@EntityGraph |
|
|---|---|---|
| Sintaxis | Dentro del JPQL | Anotación declarativa |
| Sobre consultas derivadas | No | Sí |
| Rutas anidadas | Sí | Sí ("bicicleta.estacion") |
Control del tipo de JOIN |
Sí (left/inner) |
No (siempre LEFT) |
| Reutilizable | No | Sí, con @NamedEntityGraph |
Y con @NamedEntityGraph(name = "Estacion.conBicicletas", attributeNodes = @NamedAttributeNode("bicicletas")) sobre la entidad, el grafo se define una vez y se referencia por nombre desde cualquier método con @EntityGraph("Estacion.conBicicletas").
Recuerda que el límite de la paginación con colecciones (apartado 7) se aplica igual: @EntityGraph sobre una colección con Pageable también pagina en memoria.
Specification y la API Criteria
Specification y la API CriteriaEl caso que ninguna técnica anterior resuelve bien: el buscador de estaciones con filtros opcionales combinables. El ayuntamiento quiere filtrar por nombre, capacidad mínima, estado activo y disponibilidad de bicicletas, en cualquier combinación; con consultas derivadas harían falta 16 métodos y con @Query un where lleno de (:param is null or ...) ilegible.
Primero, el repositorio extiende también JpaSpecificationExecutor<Estacion>. Después, una clase con los predicados:
package com.ciclourbana.estaciones;
public final class EstacionSpecs {
private EstacionSpecs() { }
public static Specification<Estacion> nombreContiene(String texto) {
return (raiz, consulta, cb) -> (texto == null || texto.isBlank()) ? null
: cb.like(cb.lower(raiz.get("nombre")), "%" + texto.toLowerCase() + "%");
}
public static Specification<Estacion> capacidadMinima(Integer minimo) {
return (raiz, consulta, cb) -> minimo == null ? null
: cb.greaterThanOrEqualTo(raiz.get("capacidad"), minimo);
}
// activa(Boolean) es análoga: null si el filtro no viene, cb.equal si viene
public static Specification<Estacion> conBicicletasDisponibles() {
return (raiz, consulta, cb) -> {
Join<Estacion, Bicicleta> bicis = raiz.join("bicicletas", JoinType.INNER);
consulta.distinct(true);
return cb.equal(bicis.get("estado"), EstadoBicicleta.DISPONIBLE);
};
}
}Y el servicio las combina, con Specification.where(...) y and:
@Transactional(readOnly = true)
public PaginaResponse<EstacionResponse> buscar(FiltroEstaciones filtro, Pageable pageable) {
Specification<Estacion> spec = Specification
.where(EstacionSpecs.nombreContiene(filtro.nombre()))
.and(EstacionSpecs.capacidadMinima(filtro.capacidadMinima()))
.and(EstacionSpecs.activa(filtro.activa()));
if (Boolean.TRUE.equals(filtro.soloConBicicletas())) {
spec = spec.and(EstacionSpecs.conBicicletasDisponibles());
}
return PaginaResponse.de(
estacionRepositorio.findAll(spec, pageable).map(mapper::aRespuesta));
}La clave está en devolver null. Spring Data descarta los predicados nulos, así que un filtro ausente no aparece en el WHERE: nada de concatenar SQL ni de if anidados, y cualquier combinación de filtros funciona sin escribir código adicional. Specification es además compatible con Pageable y Sort.
Su contrapartida es la verbosidad de la API Criteria —cb.greaterThanOrEqualTo(raiz.get("capacidad"), minimo) se lee peor que capacidad >= :minimo— y que raiz.get("capacidad") es una cadena que ningún compilador verifica. La mitigación es el metamodelo estático de JPA, que genera clases Estacion_ con constantes tipadas: raiz.get(Estacion_.capacidad).
@NamedQuery, Streamable y Stream
@NamedQuery, Streamable y StreamConsultas con nombre. Se declaran en la entidad con @NamedQuery(name = "Estacion.buscarSinBicicletas", query = "select e from Estacion e where e.bicicletas is empty") y se invocan declarando en el repositorio un método buscarSinBicicletas(), que Spring Data resuelve por el nombre Estacion.<método>. Su ventaja histórica era la validación al arrancar, pero @Query también se valida hoy, y su inconveniente es que aleja la consulta del repositorio que la usa y ensucia la entidad. En CicloUrbana no las usamos.
Streamable<T> es un Iterable enriquecido con map, filter y and, útil para componer resultados sin recurrir a Stream. Y Stream<T> procesa resultados grandes sin cargarlos todos en memoria, con una regla estricta:
@Transactional(readOnly = true)
public void exportarAlquileres(Writer salida) {
try (Stream<Alquiler> flujo = alquilerRepositorio.streamAllByFinIsNotNull()) {
flujo.forEach(a -> escribirLinea(salida, a));
}
}Tres condiciones obligatorias: try-with-resources, porque el Stream mantiene abierto un cursor JDBC; transacción activa durante todo el consumo; y vaciar el contexto periódicamente con entityManager.clear() si se recorren cientos de miles de filas, o todas quedarán gestionadas y la memoria se agotará. Sin el cierre, la conexión no vuelve al pool: la fuga de 04-02.
- Verificar el SQL realmente ejecutado
Con tantos mecanismos, la única forma de saber qué ocurre es mirarlo. La configuración es la de 04-02:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
spring:
jpa:
properties:
hibernate:
format_sql: true
generate_statistics: trueY las tres preguntas que hay que hacerle a cada endpoint nuevo: ¿cuántas consultas ejecuta? —debe ser un número constante, no proporcional a los resultados (04-04)—; ¿trae columnas que no usa? —si el DTO tiene 4 campos y el SELECT trae 20, falta una proyección—; y ¿son las que esperabas?, porque una consulta que no reconoces suele ser una carga perezosa disparada por accidente.
Para casos difíciles, EXPLAIN ANALYZE de PostgreSQL muestra el plan real de ejecución: si aparece un Seq Scan sobre una tabla grande, falta un índice. Ese análisis pertenece a 09-01.
Errores Comunes y Consejos
Nombres de método kilométricos. Cuando pase de tres o cuatro condiciones, pasa a @Query. La legibilidad importa más que la brevedad del código.
Usar parámetros posicionales (?1, ?2). Reordenar los argumentos rompe la consulta sin que el compilador diga nada. Usa @Param.
Olvidar clearAutomatically en @Modifying. El contexto queda desincronizado y el dirty checking puede deshacer la actualización masiva.
Hacer JOIN FETCH de dos colecciones. Producto cartesiano. Una colección por consulta.
Paginar con JOIN FETCH de colecciones. Hibernate avisa con HHH90003004 y carga todo en memoria. Usa @BatchSize o dos consultas.
Abusar de las consultas nativas. Rompen la portabilidad y no se validan al arrancar. Solo cuando JPQL no puede.
Consumir un Stream sin try-with-resources. Deja abierto un cursor y una conexión: una fuga del pool.
Consejo: proyecta siempre que no vayas a modificar. Si el endpoint solo lee, un record de proyección o un select new evitan cargar entidades, reducen memoria y eliminan el riesgo de LazyInitializationException.
Consejo: declara el countQuery en las consultas paginadas complejas. La derivación automática falla con JOIN FETCH y DISTINCT.
Consejo: usa Specification para los buscadores. Cualquier combinación de filtros opcionales sin un solo if de concatenación.
Consejo: mira el log de SQL de cada endpoint nuevo antes de darlo por terminado. Cuesta un minuto y encuentra el 90 % de los problemas de esta lección.
Ejercicios
Ejercicio 1: elegir el mecanismo
Para cada necesidad de CicloUrbana, elige el mecanismo (consulta derivada, @Query JPQL, nativa, proyección, Specification) y escribe la firma del método.
- Bicicletas de una estación con estado
DISPONIBLE. - Las 10 estaciones más cercanas a una coordenada, con la distancia en kilómetros.
- Buscador con cuatro filtros opcionales combinables y paginación.
- Recuento de alquileres por estación de origen del último mes, para un informe.
- Todas las bicicletas con batería inferior al umbral, pasadas a
MANTENIMIENTOen una sola operación.
Ejercicio 2: informe de facturación
Escribe la consulta que produce el informe mensual de CicloUrbana: por cada usuario con al menos un alquiler finalizado en el mes, su nombre, correo, número de alquileres, minutos totales e importe total facturado, ordenado por importe descendente y paginado. Define el DTO y el método del repositorio.
Ejercicio 3: encontrar cuatro fallos
Este repositorio tiene cuatro problemas. Identifícalos y corrígelos.
public interface AlquilerRepositorio extends JpaRepository<Alquiler, Long> {
@Query("select a from Alquiler a join fetch a.usuario join fetch a.bicicleta " +
"where a.inicio between ?1 and ?2")
Page<Alquiler> buscarPorPeriodo(Instant desde, Instant hasta, Pageable pageable);
@Query(value = "SELECT * FROM alquileres WHERE importe > :minimo", nativeQuery = true)
List<Alquiler> buscarCaros(@Param("minimo") BigDecimal minimo);
@Modifying
@Query("update Alquiler a set a.estado = 'CADUCADO' where a.fin is null " +
"and a.inicio < :limite")
int caducarAbandonados(@Param("limite") Instant limite);
}Soluciones
Solución 1.
-
Consulta derivada. Dos condiciones simples sobre propiedades directas:
List<Bicicleta> findByEstacionIdAndEstado(Long estacionId, EstadoBicicleta estado); -
Consulta nativa con proyección por interfaz. JPQL no tiene funciones trigonométricas, así que la fórmula del semiverseno solo es expresable en SQL:
List<EstacionCercanaProyeccion> buscarMasCercanas(...)connativeQuery = true. -
Specification. Cuatro filtros opcionales son 16 combinaciones, y ninguna otra técnica lo cubre sin duplicar código: basta elPage<Estacion> findAll(Specification<Estacion>, Pageable)heredado deJpaSpecificationExecutor. -
@QueryJPQL conselect new. Es una agregación congroup by, imposible en una consulta derivada, y no necesita entidades:
@Query("""
select new com.ciclourbana.alquileres.dto.AlquileresPorEstacion(
a.estacionOrigen.id, a.estacionOrigen.nombre, count(a))
from Alquiler a
where a.inicio >= :desde
group by a.estacionOrigen.id, a.estacionOrigen.nombre
order by count(a) desc
""")
List<AlquileresPorEstacion> contarPorEstacionOrigen(@Param("desde") Instant desde);@Modifying. Operación masiva sobre muchas filas: cargarlas todas para modificarlas sería absurdo:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Bicicleta b
set b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.MANTENIMIENTO
where b.nivelBateria < :umbral
and b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.DISPONIBLE
""")
int marcarParaMantenimiento(@Param("umbral") int umbral);Solución 2.
package com.ciclourbana.alquileres.dto;
public record FacturacionUsuario(Long usuarioId, String nombre, String correo,
long numeroAlquileres, long minutosTotales,
BigDecimal importeTotal) { }@Query(value = """
select new com.ciclourbana.alquileres.dto.FacturacionUsuario(
u.id, u.nombre, u.correo,
count(a.id),
sum(function('extract', epoch from (a.fin - a.inicio))) / 60,
sum(a.importe))
from Alquiler a
join a.usuario u
where a.fin is not null
and a.inicio >= :desde
and a.inicio < :hasta
group by u.id, u.nombre, u.correo
having sum(a.importe) > 0
order by sum(a.importe) desc
""",
countQuery = """
select count(distinct a.usuario.id) from Alquiler a
where a.fin is not null
and a.inicio >= :desde and a.inicio < :hasta
""")
Page<FacturacionUsuario> facturacionDelPeriodo(@Param("desde") Instant desde,
@Param("hasta") Instant hasta,
Pageable pageable);Decisiones y por qué:
select newconrecord: el resultado es directamente el DTO de respuesta, sin cargar ni unUsuarioni unAlquilercomo entidad. Con miles de alquileres, la diferencia de memoria es de órdenes de magnitud.countQuerydeclarado: congroup by, la derivación automática contaría filas agrupadas y daría un número de páginas incorrecto.count(distinct a.usuario.id)es el real.join a.usuario uen lugar de navegara.usuario.nombrerepetidamente: un soloJOINexplícito, más legible y con un solo alias en elgroup by.>= :desde and < :hastaen vez debetween, que es inclusivo en ambos extremos y duplicaría el último instante entre dos meses consecutivos: el intervalo semiabierto es el correcto para rangos de fechas.function('extract', ...)invoca una función del motor desde JPQL, con la dependencia de PostgreSQL que eso implica; la alternativa portable sería guardar la duración en minutos como columna al finalizar el alquiler, evitando además el cálculo en cada informe.
Solución 3. Los cuatro problemas:
1. Page con JOIN FETCH sin countQuery. Aunque aquí son asociaciones @ManyToOne y no colecciones —lo que evita el aviso HHH90003004—, Spring Data intentará derivar el recuento de una consulta con fetch y fallará o generará un count con JOIN innecesarios. Hay que declararlo.
2. Parámetros posicionales. ?1 y ?2 son frágiles: intercambiar desde y hasta en la firma no da error de compilación y produce una consulta que nunca devuelve nada. Usa @Param.
3. La consulta nativa devuelve entidades con SELECT *. Funciona mientras las columnas coincidan, se rompe en silencio al añadir o renombrar una, y no se valida al arrancar. Además no necesita ser nativa: importe > :minimo es JPQL puro.
4. @Modifying sin clearAutomatically ni flushAutomatically, y con un literal de enumerado. El contexto queda con alquileres obsoletos, y 'CADUCADO' entre comillas es un literal de cadena que Hibernate puede no convertir al tipo enumerado. Falta además @Transactional. Versión corregida:
public interface AlquilerRepositorio extends JpaRepository<Alquiler, Long> {
@Query(value = "select a from Alquiler a join fetch a.usuario join fetch a.bicicleta "
+ "where a.inicio >= :desde and a.inicio < :hasta",
countQuery = "select count(a) from Alquiler a "
+ "where a.inicio >= :desde and a.inicio < :hasta")
Page<Alquiler> buscarPorPeriodo(@Param("desde") Instant desde,
@Param("hasta") Instant hasta, Pageable pageable);
@Query("select a from Alquiler a where a.importe > :minimo")
List<Alquiler> buscarCaros(@Param("minimo") BigDecimal minimo);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
update Alquiler a
set a.estado = com.ciclourbana.alquileres.EstadoAlquiler.CADUCADO
where a.fin is null and a.inicio < :limite
""")
int caducarAbandonados(@Param("limite") Instant limite);
}Conclusión
CicloUrbana ya sabe preguntar. Dominas las consultas derivadas del nombre del método, entendiendo cómo se analiza el nombre en sujeto y predicado, la tabla completa de palabras clave y —lo más valioso— que el error aparece al arrancar con un PropertyReferenceException que hasta sugiere el nombre correcto. Sabes navegar propiedades anidadas, desambiguar con _ cuando hace falta y reconocer las señales que indican que el nombre del método se ha quedado corto: más de tres condiciones, mezcla de And y Or, agregaciones, subconsultas o filtros opcionales. Escribes JPQL con @Query sobre entidades y propiedades en lugar de tablas y columnas, con parámetros nombrados y bloques de texto, declarando el countQuery cuando la consulta paginada es compleja. Y proyectas a DTO con select new, la técnica de mayor impacto de la lección: una sola consulta, cuatro columnas, ninguna entidad gestionada y el DTO de respuesta construido directamente.
Resuelves el N+1 de 04-04 con JOIN FETCH, conociendo sus dos límites —no paginar colecciones, una sola colección por consulta— y su alternativa declarativa @EntityGraph, con o sin @NamedEntityGraph. Recurres a consultas nativas solo cuando JPQL no alcanza, como la fórmula del semiverseno de las estaciones más cercanas, asumiendo conscientemente sus riesgos de portabilidad y de validación tardía. Manejas @Modifying para operaciones masivas sabiendo por qué clearAutomatically y flushAutomatically no son opcionales: una consulta de modificación se salta el contexto de persistencia, la auditoría, las cascadas y el bloqueo optimista. Eliges entre proyecciones por interfaz cerrada, abierta con SpEL, por record y dinámicas con genéricos, sabiendo cuál optimiza el SELECT y cuál no. Y construyes el buscador de estaciones de Ribalta con Specification, donde devolver null para un filtro ausente permite que cualquier combinación funcione sin un solo if.
Queda una pieza que ha aparecido en cada ejemplo sin explicarse del todo: @Transactional. La hemos puesto en los servicios, la hemos necesitado en @Modifying, hemos dicho que el contexto de persistencia vive lo que dura la transacción, que el dirty checking guarda sin llamar a save() y que readOnly = true optimiza algo. Todo eso son afirmaciones que aún no hemos justificado. La lección 04-07, Transacciones y Gestión de la Persistencia, las justifica: qué es una transacción y qué significan las propiedades ACID cuando iniciar un alquiler exige marcar la bicicleta y crear el registro juntos o ninguno; dónde poner @Transactional y por qué; cómo funciona por dentro y las dos trampas que hacen que a veces no funcione en absoluto; los siete valores de propagation y los cuatro de isolation; la regla del rollback y el error clásico de capturarla y quedarse sin él; el bloqueo pesimista frente al optimista de @Version en la carrera por la última bicicleta de una estación; y los eventos transaccionales que permiten cobrar el alquiler después del commit sin retener una conexión del pool.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
