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

  1. Consultas derivadas del nombre del método
  2. Tabla exhaustiva de palabras clave
  3. Propiedades anidadas y ambigüedades
  4. Cuándo abandonar el nombre del método
  5. @Query con JPQL
  6. Proyecciones a DTO en la consulta
  7. JOIN FETCH y la resolución del N+1
  8. Consultas nativas
  9. @Modifying: UPDATE y DELETE
  10. Proyecciones por interfaz y por record
  11. @EntityGraph
  12. Specification y la API Criteria
  13. @NamedQuery, Streamable y Stream
  14. Verificar el SQL realmente ejecutado
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. 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.

  1. 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.

  1. 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 correo

Spring Data genera un JOIN automáticamente:

select b.* from bicicletas b join estaciones e on e.id = b.estacion_id
 where e.nombre = ?

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.

  1. 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.

  1. @Query con JPQL

JPQL (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.

  1. 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.

  1. JOIN FETCH y la resolución del N+1

Cuando 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.

  1. 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.

  1. @Modifying: UPDATE y DELETE

Por 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.

  1. Proyecciones por interfaz y por record

Una 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

  1. @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 consulta

Genera 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.

  1. Specification y la API Criteria

El 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).

  1. @NamedQuery, Streamable y Stream

Consultas 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.

  1. 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: true

Y 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.

  1. Bicicletas de una estación con estado DISPONIBLE.
  2. Las 10 estaciones más cercanas a una coordenada, con la distancia en kilómetros.
  3. Buscador con cuatro filtros opcionales combinables y paginación.
  4. Recuento de alquileres por estación de origen del último mes, para un informe.
  5. Todas las bicicletas con batería inferior al umbral, pasadas a MANTENIMIENTO en 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.

  1. Consulta derivada. Dos condiciones simples sobre propiedades directas: List<Bicicleta> findByEstacionIdAndEstado(Long estacionId, EstadoBicicleta estado);

  2. 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(...) con nativeQuery = true.

  3. Specification. Cuatro filtros opcionales son 16 combinaciones, y ninguna otra técnica lo cubre sin duplicar código: basta el Page<Estacion> findAll(Specification<Estacion>, Pageable) heredado de JpaSpecificationExecutor.

  4. @Query JPQL con select new. Es una agregación con group 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);
  1. @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 new con record: el resultado es directamente el DTO de respuesta, sin cargar ni un Usuario ni un Alquiler como entidad. Con miles de alquileres, la diferencia de memoria es de órdenes de magnitud.
  • countQuery declarado: con group 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 u en lugar de navegar a.usuario.nombre repetidamente: un solo JOIN explícito, más legible y con un solo alias en el group by.
  • >= :desde and < :hasta en vez de between, 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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados