Amb la lliçó anterior, CicloUrbana sap desar, cercar per identificador, comptar i paginar. Sap fer l'elemental. Però la xarxa de Ribalta necessita molt més: les estacions amb lloc lliure, les bicicletes amb la bateria per sota del llindar, els lloguers d'un usuari entre dues dates, les estacions més properes a una coordenada, l'informe d'ocupació en una sola consulta. Tot això són preguntes, i Spring Data ofereix cinc formes diferents de formular-les.

Aquesta és la lliçó més pràctica del mòdul i la que més canvia el dia a dia. Aquí hi ha l'arsenal complet: les consultes derivades del nom del mètode —el mecanisme que més sorprèn qui arriba a Spring Data—, @Query amb JPQL, les consultes natives de PostgreSQL, les modificacions massives amb @Modifying, les projeccions que eviten carregar entitats senceres, @EntityGraph per resoldre el N+1 de 04-04 i les Specification per a filtres dinàmics. I, transversal a tot, el criteri per triar: cada mecanisme té un punt en què deixa de ser l'eina adequada.

Contingut

  1. Consultes derivades del nom del mètode
  2. Taula exhaustiva de paraules clau
  3. Propietats imbricades i ambigüitats
  4. Quan abandonar el nom del mètode
  5. @Query amb JPQL
  6. Projeccions a DTO a la consulta
  7. JOIN FETCH i la resolució del N+1
  8. Consultes natives
  9. @Modifying: UPDATE i DELETE
  10. Projeccions per interfície i per record
  11. @EntityGraph
  12. Specification i l'API Criteria
  13. @NamedQuery, Streamable i Stream
  14. Verificar el SQL realment executat
  15. Errors Comuns i Consells
  16. Exercicis

  1. Consultes derivades del nom del mètode

El mecanisme és simple d'enunciar i sorprenent la primera vegada: declares un mètode amb un nom que descriu la consulta i Spring Data la genera.

public interface BicicletaRepositori extends JpaRepository<Bicicleta, Long> {

    Optional<Bicicleta> findByMatricula(String matricula);

    List<Bicicleta> findByEstat(EstatBicicleta estat);

    List<Bicicleta> findByEstatAndNivellBateriaGreaterThanEqual(
            EstatBicicleta estat, int nivellMinim);

    long countByEstacioId(Long estacioId);
    boolean existsByMatricula(String matricula);
}

Cap no té implementació i tots cinc funcionen. A l'arrencada, PartTreeJpaQuery analitza cada nom i construeix la consulta.

Com s'analitza el nom. Es divideix en dues parts: el subjecte (findByEstat... → find) indica què es retorna —find, read, get, query, search són equivalents; a més count, exists, delete, amb Distinct i limitadors com Top10 o First—, i el predicat, que comença a By i descriu el filtre amb noms de propietat, operadors i connectors.

L'anàlisi és estricta. findByNivellBateria funciona perquè Bicicleta té un camp nivellBateria; findByBateria falla en arrencar:

org.springframework.data.mapping.PropertyReferenceException:
No property 'bateria' found for type 'Bicicleta'. Did you mean 'nivellBateria'?

És un gran avantatge: l'error apareix en arrencar, no a la primera petició. Reanomenar un camp de l'entitat trenca l'arrencada i obliga a corregir els mètodes que el feien servir: la validació primerenca que les cadenes de SQL no donen mai.

  1. Taula exhaustiva de paraules clau

Paraula clau Exemple a CicloUrbana JPQL generat (fragment)
findBy findByNom(String n) where e.nom = ?1
readBy/getBy/queryBy Sinònims de findBy Igual
countBy countByEstatAndEstacioId(...) select count(e) where ...
existsBy existsByMatricula(String m) select count(e) > 0 where ...
deleteBy/removeBy deleteByEstatAndFiIsNotNull(...) delete from ... where ...
And findByEstatAndActivaTrue(...) where a = ?1 and b = true
Or findByEstatOrNivellBateriaLessThan(...) where a = ?1 or b < ?2
Between findByIniciBetween(Instant d, Instant f) where e.inici between ?1 and ?2
LessThan / LessThanEqual findByNivellBateriaLessThan(int n) where e.nivellBateria < ?1
GreaterThan / GreaterThanEqual findByCapacitatGreaterThanEqual(int c) where e.capacitat >= ?1
After / Before findByIniciAfter(Instant i) where e.inici > ?1
Like / NotLike findByNomLike(String p) where e.nom like ?1
Containing findByNomContaining(String t) where e.nom like %?1%
StartingWith / EndingWith findByMatriculaStartingWith("RB-") where e.matricula like ?1%
In / NotIn findByEstatIn(List<EstatBicicleta> l) where e.estat in ?1
IsNull / IsNotNull findByFiIsNull() where e.fi is null
True / False findByActivaTrue() where e.activa = true
IgnoreCase findByNomIgnoreCase(String n) where upper(e.nom) = upper(?1)
OrderBy...Asc/Desc findByActivaTrueOrderByNomAsc() order by e.nom asc
Top/First findTop5ByOrderByIniciDesc() limit 5
Distinct findDistinctByEstacioId(Long id) select distinct e
Not findByEstatNot(EstatBicicleta e) where e.estat <> ?1

Els repositoris de CicloUrbana amb consultes útils reals:

public interface EstacioRepositori extends JpaRepository<Estacio, Long> {
    Optional<Estacio> findByNomIgnoreCase(String nom);
    boolean existsByNom(String nom);
    List<Estacio> findByActivaTrueOrderByNomAsc();
    List<Estacio> findByCapacitatGreaterThanEqual(int capacitatMinima);
    List<Estacio> findByNomContainingIgnoreCase(String text);
    Page<Estacio> findByActivaTrue(Pageable pageable);
}

public interface LloguerRepositori extends JpaRepository<Lloguer, Long> {
    List<Lloguer> findByUsuariIdAndFiIsNull(Long usuariId);              // en curs
    Page<Lloguer> findByUsuariIdOrderByIniciDesc(Long id, Pageable p);
    List<Lloguer> findByIniciBetween(Instant desDe, Instant finsA);
    long countByBicicletaIdAndFiIsNotNull(Long bicicletaId);
    List<Lloguer> findTop10ByOrderByImportTotalDesc();                   // més cars
}

Fixa't en findByUsuariIdAndFiIsNull: és la consulta que respon «té aquest usuari un lloguer en curs?», la regla de negoci central de CicloUrbana, i cap al nom d'un mètode sense escriure SQL ni JPQL. Un detall pràctic: l'ordre dels paràmetres ha de coincidir amb l'ordre en què apareixen al nom; invertir-los no compila si els tipus difereixen, però amb dos paràmetres del mateix tipus l'error passa a execució i produeix resultats incorrectes en silenci. Bon motiu per no encadenar massa condicions.

  1. Propietats imbricades i ambigüitats

Pots navegar a propietats d'entitats relacionades:

List<Bicicleta> findByEstacioNom(String nom);       // bicicletes de l'estació "X"
List<Lloguer> findByUsuariCorreu(String correu);    // lloguers d'aquest correu

Spring Data genera un JOIN automàticament:

select b.* from bicicletes b join estacions e on e.id = b.estacio_id
 where e.nom = ?

El problema de l'ambigüitat. L'analitzador resol findByEstacioNom de forma voraç: primer busca una propietat estacioNom a Bicicleta i, si no la troba, parteix per l'última majúscula i busca estacio.nom. Si existissin totes dues guanyaria la primera, i la consulta seria una altra de la que pretenies sense cap error. Per desambiguar es fa servir el guió baix: findByEstacio_Nom(String nom) és inequívoc. És lleig i trenca la convenció de noms de Java, però és explícit.

Compte amb la navegació profunda: findByBicicletaEstacioUbicacioLatitud(...) genera tres JOIN encadenats en un nom il·legible. Quan hi arribis, passa a @Query.

  1. Quan abandonar el nom del mètode

Les consultes derivades són excel·lents fins a cert punt, i aquest punt es reconeix sense ambigüitat:

Senyal Exemple
El nom supera uns 60 caràcters findByEstatAndNivellBateriaLessThanAndEstacioActivaTrueOrderByNivellBateriaAsc
Hi ha més de tres o quatre condicions Qualsevol amb tres And i un Or
Barreja And i Or La precedència no és evident en llegir-lo
Necessita agregacions count, sum, avg sobre columnes
Necessita subconsultes «estacions sense cap bicicleta disponible»
Necessita JOIN FETCH Resoldre el N+1
El filtre és opcional Filtres que poden venir o no

Les alternatives, per ordre de preferència: @Query amb JPQL per a consultes complexes però portables; consulta nativa quan cal SQL específic de PostgreSQL; Specification per a filtres dinàmics i combinables; i un repositori propi (Impl) per a lògica que no cap en una sola consulta.

  1. @Query amb JPQL

JPQL (Jakarta Persistence Query Language) és SQL sobre el model d'objectes: consulta entitats i les seves propietats, no taules i columnes.

@Query("""
       select e from Estacio e
        where e.activa = true
          and e.capacitat >= :capacitatMinima
        order by e.nom
       """)
List<Estacio> cercarActivesAmbCapacitat(@Param("capacitatMinima") int capacitatMinima);

Fixa't en Estacio amb majúscula i e.capacitat: són el nom de la classe Java i el del seu camp, no estacions ni la columna. Això és el que fa JPQL portable entre motors i verificable contra el model. I els blocs de text de Java (""") són la forma correcta d'escriure consultes de diverses línies, sense concatenació ni espais perduts.

Paràmetres posicionals enfront de nomenats:

// Posicionals: fràgils. Reordenar els arguments trenca la consulta en silenci
@Query("select e from Estacio e where e.capacitat >= ?1 and e.activa = ?2")
List<Estacio> cercar(int capacitat, boolean activa);

// Nomenats: la forma recomanada
@Query("select e from Estacio e where e.capacitat >= :capacitat and e.activa = :activa")
List<Estacio> cercar(@Param("capacitat") int capacitat, @Param("activa") boolean activa);

Fes servir sempre paràmetres nomenats amb @Param. Els posicionals depenen de l'ordre dels arguments i no donen cap pista en llegir-los. I un advertiment heretat del món JDBC: mai concatenis valors al text de la consulta, perquè això és una injecció SQL. Els paràmetres de JPA viatgen en sentències preparades, amb els valors separats del text: aquesta és la seva protecció.

Consultes paginades amb @Query i el seu countQuery:

@Query(value = "select l from Lloguer l where l.usuari.id = :usuariId "
             + "and l.inici between :desDe and :finsA",
       countQuery = "select count(l) from Lloguer l where l.usuari.id = :usuariId "
                  + "and l.inici between :desDe and :finsA")
Page<Lloguer> cercarPerUsuariIPeriode(@Param("usuariId") Long usuariId,
                                      @Param("desDe") Instant desDe,
                                      @Param("finsA") Instant finsA,
                                      Pageable pageable);

Spring Data pot derivar el countQuery automàticament, però amb consultes complexes —sobretot amb JOIN FETCH o DISTINCT— la derivació falla o genera un recompte molt més car del necessari. Declarar-lo és l'opció segura.

  1. Projeccions a DTO a la consulta

Una de les tècniques de més impacte de tota la lliçó: portar només les columnes necessàries i construir directament el DTO, sense passar per entitats gestionades.

package com.ciclourbana.estacions.dto;

public record OcupacioEstacio(Long estacioId, String nom, int capacitat,
                              long bicicletesDisponibles) { }
@Query("""
       select new com.ciclourbana.estacions.dto.OcupacioEstacio(
              e.id, e.nom, e.capacitat, count(b.id))
         from Estacio e
         left join e.bicicletes b
              on b.estat = com.ciclourbana.bicicletes.EstatBicicleta.DISPONIBLE
        where e.activa = true
        group by e.id, e.nom, e.capacitat
        order by e.nom
       """)
List<OcupacioEstacio> consultarOcupacio();

Requisits del select new: nom de classe completament qualificat i un constructor els tipus del qual coincideixin exactament amb els de les expressions. Un record ho compleix de forma natural. Compara amb l'alternativa de carregar entitats:

Carregar entitats i comptar en Java select new amb count
Consultes 1 + N (o 1 amb JOIN FETCH) 1
Dades transferides Totes les columnes d'estacions i bicicletes 4 columnes per estació
Entitats al context Milers Cap
Risc de LazyInitializationException Sí No
Reutilitzable com a resposta Requereix mapatge Ja és el DTO

És la solució que vam triar a l'exercici 2 de 04-04, ara escrita del tot. La regla: si l'endpoint no modificarà res, planteja't si necessita entitats en absolut.

  1. JOIN FETCH i la resolució del N+1

Quan sí que necessites entitats amb les seves relacions carregades, JOIN FETCH les porta en una sola consulta.

@Query("""
       select distinct e from Estacio e
         left join fetch e.bicicletes
        where e.id = :id
       """)
Optional<Estacio> cercarAmbBicicletes(@Param("id") Long id);

join fetch enfront de join a seques. Un join normal serveix per filtrar: pots posar condicions sobre l'entitat unida, però no es carrega, així que accedir-hi després dispara consultes mandroses. join fetch filtra i carrega. La distinció és subtil i crítica.

Per què distinct. Amb un JOIN a una col·lecció, la base de dades retorna una fila per bicicleta, és a dir, l'estació repetida N vegades; sense distinct, la llista contindria duplicats. A Hibernate 6 s'aplica en memòria sense afegir-lo al SQL, així que no penalitza. Pots encadenar diversos nivells:

@Query("""
       select distinct l from Lloguer l
         join fetch l.usuari
         join fetch l.bicicleta b
         join fetch b.estacio
        where l.fi is null
       """)
List<Lloguer> cercarEnCursAmbDetall();

Quatre entitats en una sola consulta, en lloc d'1 + 3N.

Els dos límits de JOIN FETCH. El primer: no es pot paginar amb col·leccions; ja ho vam veure a 04-04, Hibernate avisa amb HHH90003004 i pagina en memòria, així que amb col·leccions i paginació cal fer servir @BatchSize o dues consultes (una d'ids paginats i una altra amb where id in). El segon: un sol fetch de col·lecció per consulta, perquè fer join fetch de dues col·leccions diferents genera un producte cartesià —30 bicicletes × 20 incidències = 600 files per a una estació—; Hibernate 6 ho permet, però gairebé sempre és un error.

  1. Consultes natives

Quan JPQL no abasta, nativeQuery = true executa SQL directe del motor.

@Query(value = """
               SELECT e.id, e.nom, e.adreca, e.capacitat,
                      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 estacions e
                WHERE e.activa = true
                ORDER BY distancia_km ASC
                LIMIT :limit
               """, nativeQuery = true)
List<EstacioProperaProjeccio> cercarMesProperes(@Param("latitud") double latitud,
                                                @Param("longitud") double longitud,
                                                @Param("limit") int limit);

Aquesta expressió és la fórmula del semivers (haversine), que calcula la distància sobre la superfície terrestre entre dues coordenades. JPQL no té funcions trigonomètriques, així que no hi ha alternativa portable. La projecció que recull el resultat (apartat 10):

public interface EstacioProperaProjeccio {
    Long getId();  String getNom();  String getAdreca();
    Integer getCapacitat();
    Double getDistanciaKm();   // mapeja la columna distancia_km
}

Quan es justifica una consulta nativa: funcions específiques del motor (geoespacials, JSONB, text complet); funcions de finestra (ROW_NUMBER, LAG, RANK), que JPQL no admet; CTE (WITH ... AS) i consultes recursives; optimitzacions concretes com l'ON CONFLICT de PostgreSQL; i operacions massives on el rendiment mana.

Els seus riscos, que cal assumir conscientment:

Risc Conseqüència
Portabilitat El SQL de PostgreSQL no funciona a H2, encara que MODE=PostgreSQL ajuda
Noms físics Reanomenar una columna a l'entitat no actualitza la consulta
Sense validació Els errors apareixen en execució, no en arrencar
Fora del context Retorna dades crues; les entitats carregades no se sincronitzen
Proves Obliguen a provar contra PostgreSQL real (Testcontainers, 06-05)

El segon risc és el més traïdor: una consulta nativa referencia nivell_bateria, algú reanomena el camp a l'entitat i actualitza l'esquema, i la consulta compila, arrenca i falla en producció. La regla de CicloUrbana: JPQL per defecte, natiu només quan JPQL no pot; i quan facis servir natiu, cobreix-lo amb una prova d'integració.

  1. @Modifying: UPDATE i DELETE

Per defecte, @Query assumeix una consulta de lectura. Per modificar cal @Modifying:

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
       update Bicicleta b
          set b.estat = com.ciclourbana.bicicletes.EstatBicicleta.MANTENIMENT
        where b.nivellBateria < :llindar
          and b.estat = com.ciclourbana.bicicletes.EstatBicicleta.DISPONIBLE
       """)
int marcarPerMantenimentPerBateria(@Param("llindar") int llindar);

Retorna el nombre de files afectades. Tres coses són obligatòries o gairebé:

@Transactional és imprescindible. Sense ella, TransactionRequiredException: Executing an update/delete query. L'habitual és que la transacció vingui del servei (04-07).

flushAutomatically = true aboca a l'UPDATE els canvis pendents del context abans d'executar la consulta; sense ell, una bicicleta modificada en memòria i encara no abocada no compliria el WHERE que hauria de complir.

clearAutomatically = true és el més important i el pitjor entès. Una consulta de modificació s'executa directament a la base de dades, saltant-se el context de persistència, així que les entitats ja carregades queden amb valors obsolets:

@Transactional
public void exempleDesincronitzacio() {
    Bicicleta bici = bicicletaRepositori.findById(1L).orElseThrow();  // DISPONIBLE
    bicicletaRepositori.marcarPerMantenimentPerBateria(20);           // UPDATE a la BD
    bici.getEstat();   // continua dient DISPONIBLE! Ve del context, no de la BD
}

Encara pitjor: si després es modifica bici i es fa commit, el dirty checking escriuria l'estat antic, desfent l'actualització massiva. clearAutomatically = true buida el context després de la consulta, forçant a rellegir.

graph TD
    A["findById(1) -> context: bici DISPONIBLE"] --> B["@Modifying UPDATE a la BD"]
    B --> C{"clearAutomatically"}
    C -->|false| D["El context conserva DISPONIBLE<br/>(dades obsoletes)"]
    C -->|true| E["Context buidat<br/>la lectura següent va a la BD"]

Quan fer servir @Modifying i quan no. És l'eina correcta per a operacions massives —marcar cent bicicletes, tancar els lloguers abandonats de la nit— i no ho és per modificar una entitat concreta: allà n'hi ha prou amb carregar-la i canviar-la, deixant treballar el dirty checking. Advertiment final: una consulta de modificació se salta les cascades, els @EntityListeners i el bloqueig optimista, de manera que modificat_el no s'actualitza i versio no s'incrementa; si això importa, fes-ho explícitament a la mateixa consulta.

  1. Projeccions per interfície i per record

Una projecció retorna un subconjunt de dades en lloc de l'entitat completa. Hi ha tres formes.

Projecció tancada per interfície. Declares una interfície amb mètodes getX() que coincideixen amb propietats:

public interface EstacioResum {
    Long getId();  String getNom();  Integer getCapacitat();
}
// i al repositori, sense canviar el nom del mètode:
List<EstacioResum> findByActivaTrue();

Spring Data genera un proxy i, el més important, restringeix el SELECT a les columnes necessàries: select e1_0.id, e1_0.nom, e1_0.capacitat from estacions e1_0 where e1_0.activa = true. Amb una taula de trenta columnes, la diferència de dades transferides és enorme.

Projecció oberta amb @Value i SpEL. Permet calcular:

public interface EstacioEtiquetada {
    String getNom();  Integer getCapacitat();

    @Value("#{target.nom + ' (' + target.capacitat + ' places)'}")
    String getEtiqueta();
}

Té un cost que convé conèixer: amb @Value, Spring Data carrega l'entitat completa per avaluar l'expressió, perdent l'optimització del SELECT. Fes-la servir només quan necessitis el càlcul.

Projecció a record. La més neta amb Java modern: n'hi ha prou amb declarar public record EstacioResum(Long id, String nom, Integer capacitat) { } i fer-lo servir com a tipus de retorn. Spring Data reconeix el record i fa servir el seu constructor canònic; els noms dels components han de coincidir amb els de les propietats.

Projeccions dinàmiques. El mateix mètode pot retornar formes diferents segons el que demanis:

<T> List<T> findByActivaTrue(Class<T> tipus);
// repositori.findByActivaTrue(Estacio.class)      -> entitats completes
// repositori.findByActivaTrue(EstacioResum.class) -> projecció resumida
Tipus SELECT optimitzat Calcula Sintaxi
Interfície tancada Sí No Mètodes getX()
Interfície oberta (@Value) No Sí SpEL
record Sí No La més concisa
select new a @Query Sí Sí (agregacions) Nom qualificat

  1. @EntityGraph

@EntityGraph declara, per mètode, quines associacions carregar, sense escriure JPQL:

@EntityGraph(attributePaths = {"bicicletes"})
Optional<Estacio> findById(Long id);

@EntityGraph(attributePaths = {"usuari", "bicicleta", "bicicleta.estacio"})
List<Lloguer> findByFiIsNull();   // carrega quatre entitats en una consulta

Genera els mateixos LEFT JOIN FETCH que escriuries a mà i funciona tant sobre consultes derivades com sobre @Query.

JOIN FETCH @EntityGraph
Sintaxi Dins del JPQL Anotació declarativa
Sobre consultes derivades No Sí
Rutes imbricades Sí Sí ("bicicleta.estacio")
Control del tipus de JOIN Sí (left/inner) No (sempre LEFT)
Reutilitzable No Sí, amb @NamedEntityGraph

I amb @NamedEntityGraph(name = "Estacio.ambBicicletes", attributeNodes = @NamedAttributeNode("bicicletes")) sobre l'entitat, el graf es defineix una vegada i es referencia pel nom des de qualsevol mètode amb @EntityGraph("Estacio.ambBicicletes").

Recorda que el límit de la paginació amb col·leccions (apartat 7) s'aplica igual: @EntityGraph sobre una col·lecció amb Pageable també pagina en memòria.

  1. Specification i l'API Criteria

El cas que cap tècnica anterior no resol bé: el cercador d'estacions amb filtres opcionals combinables. L'ajuntament vol filtrar per nom, capacitat mínima, estat actiu i disponibilitat de bicicletes, en qualsevol combinació; amb consultes derivades caldrien 16 mètodes i amb @Query un where ple de (:param is null or ...) il·legible.

Primer, el repositori estén també JpaSpecificationExecutor<Estacio>. Després, una classe amb els predicats:

package com.ciclourbana.estacions;

public final class EstacioSpecs {

    private EstacioSpecs() { }

    public static Specification<Estacio> nomConte(String text) {
        return (arrel, consulta, cb) -> (text == null || text.isBlank()) ? null
                : cb.like(cb.lower(arrel.get("nom")), "%" + text.toLowerCase() + "%");
    }

    public static Specification<Estacio> capacitatMinima(Integer minim) {
        return (arrel, consulta, cb) -> minim == null ? null
                : cb.greaterThanOrEqualTo(arrel.get("capacitat"), minim);
    }

    // activa(Boolean) és anàloga: null si el filtre no ve, cb.equal si ve

    public static Specification<Estacio> ambBicicletesDisponibles() {
        return (arrel, consulta, cb) -> {
            Join<Estacio, Bicicleta> bicis = arrel.join("bicicletes", JoinType.INNER);
            consulta.distinct(true);
            return cb.equal(bicis.get("estat"), EstatBicicleta.DISPONIBLE);
        };
    }
}

I el servei les combina, amb Specification.where(...) i and:

@Transactional(readOnly = true)
public PaginaResponse<EstacioResponse> cercar(FiltreEstacions filtre, Pageable pageable) {
    Specification<Estacio> spec = Specification
            .where(EstacioSpecs.nomConte(filtre.nom()))
            .and(EstacioSpecs.capacitatMinima(filtre.capacitatMinima()))
            .and(EstacioSpecs.activa(filtre.activa()));

    if (Boolean.TRUE.equals(filtre.nomesAmbBicicletes())) {
        spec = spec.and(EstacioSpecs.ambBicicletesDisponibles());
    }
    return PaginaResponse.de(
            estacioRepositori.findAll(spec, pageable).map(mapper::aResponse));
}

La clau és retornar null. Spring Data descarta els predicats nuls, així que un filtre absent no apareix al WHERE: res de concatenar SQL ni d'if imbricats, i qualsevol combinació de filtres funciona sense escriure codi addicional. Specification és a més compatible amb Pageable i Sort.

La seva contrapartida és la verbositat de l'API Criteria —cb.greaterThanOrEqualTo(arrel.get("capacitat"), minim) es llegeix pitjor que capacitat >= :minim— i que arrel.get("capacitat") és una cadena que cap compilador no verifica. La mitigació és el metamodel estàtic de JPA, que genera classes Estacio_ amb constants tipades: arrel.get(Estacio_.capacitat).

  1. @NamedQuery, Streamable i Stream

Consultes amb nom. Es declaren a l'entitat amb @NamedQuery(name = "Estacio.cercarSenseBicicletes", query = "select e from Estacio e where e.bicicletes is empty") i s'invoquen declarant al repositori un mètode cercarSenseBicicletes(), que Spring Data resol pel nom Estacio.<mètode>. El seu avantatge històric era la validació en arrencar, però @Query també es valida avui, i el seu inconvenient és que allunya la consulta del repositori que la fa servir i embruta l'entitat. A CicloUrbana no les fem servir.

Streamable<T> és un Iterable enriquit amb map, filter i and, útil per compondre resultats sense recórrer a Stream. I Stream<T> processa resultats grans sense carregar-los tots a memòria, amb una regla estricta:

@Transactional(readOnly = true)
public void exportarLloguers(Writer sortida) {
    try (Stream<Lloguer> flux = lloguerRepositori.streamAllByFiIsNotNull()) {
        flux.forEach(l -> escriureLinia(sortida, l));
    }
}

Tres condicions obligatòries: try-with-resources, perquè el Stream manté obert un cursor JDBC; transacció activa durant tot el consum; i buidar el context periòdicament amb entityManager.clear() si es recorren centenars de milers de files, o totes quedaran gestionades i la memòria s'esgotarà. Sense el tancament, la connexió no torna al pool: la fuita de 04-02.

  1. Verificar el SQL realment executat

Amb tants mecanismes, l'única forma de saber què passa és mirar-ho. La configuració és 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

I les tres preguntes que cal fer a cada endpoint nou: quantes consultes executa? —ha de ser un nombre constant, no proporcional als resultats (04-04)—; porta columnes que no fa servir? —si el DTO té 4 camps i el SELECT en porta 20, falta una projecció—; i són les que esperaves?, perquè una consulta que no reconeixes sol ser una càrrega mandrosa disparada per accident.

Per a casos difícils, EXPLAIN ANALYZE de PostgreSQL mostra el pla real d'execució: si hi apareix un Seq Scan sobre una taula gran, falta un índex. Aquesta anàlisi pertany a 09-01.

Errors Comuns i Consells

Noms de mètode quilomètrics. Quan passi de tres o quatre condicions, passa a @Query. La llegibilitat importa més que la brevetat del codi.

Fer servir paràmetres posicionals (?1, ?2). Reordenar els arguments trenca la consulta sense que el compilador digui res. Fes servir @Param.

Oblidar clearAutomatically a @Modifying. El context queda desincronitzat i el dirty checking pot desfer l'actualització massiva.

Fer JOIN FETCH de dues col·leccions. Producte cartesià. Una col·lecció per consulta.

Paginar amb JOIN FETCH de col·leccions. Hibernate avisa amb HHH90003004 i carrega tot en memòria. Fes servir @BatchSize o dues consultes.

Abusar de les consultes natives. Trenquen la portabilitat i no es validen en arrencar. Només quan JPQL no pot.

Consumir un Stream sense try-with-resources. Deixa obert un cursor i una connexió: una fuita del pool.

Consell: projecta sempre que no hagis de modificar. Si l'endpoint només llegeix, un record de projecció o un select new eviten carregar entitats, redueixen memòria i eliminen el risc de LazyInitializationException.

Consell: declara el countQuery a les consultes paginades complexes. La derivació automàtica falla amb JOIN FETCH i DISTINCT.

Consell: fes servir Specification per als cercadors. Qualsevol combinació de filtres opcionals sense ni un if de concatenació.

Consell: mira el registre de SQL de cada endpoint nou abans de donar-lo per acabat. Costa un minut i troba el 90 % dels problemes d'aquesta lliçó.

Exercicis

Exercici 1: triar el mecanisme

Per a cada necessitat de CicloUrbana, tria el mecanisme (consulta derivada, @Query JPQL, nativa, projecció, Specification) i escriu la signatura del mètode.

  1. Bicicletes d'una estació amb estat DISPONIBLE.
  2. Les 10 estacions més properes a una coordenada, amb la distància en quilòmetres.
  3. Cercador amb quatre filtres opcionals combinables i paginació.
  4. Recompte de lloguers per estació d'origen de l'últim mes, per a un informe.
  5. Totes les bicicletes amb bateria inferior al llindar, passades a MANTENIMENT en una sola operació.

Exercici 2: informe de facturació

Escriu la consulta que produeix l'informe mensual de CicloUrbana: per cada usuari amb almenys un lloguer finalitzat al mes, el seu nom, correu, nombre de lloguers, minuts totals i import total facturat, ordenat per import descendent i paginat. Defineix el DTO i el mètode del repositori.

Exercici 3: trobar quatre errades

Aquest repositori té quatre problemes. Identifica'ls i corregeix-los.

public interface LloguerRepositori extends JpaRepository<Lloguer, Long> {

    @Query("select l from Lloguer l join fetch l.usuari join fetch l.bicicleta " +
           "where l.inici between ?1 and ?2")
    Page<Lloguer> cercarPerPeriode(Instant desDe, Instant finsA, Pageable pageable);

    @Query(value = "SELECT * FROM lloguers WHERE import_total > :minim", nativeQuery = true)
    List<Lloguer> cercarCars(@Param("minim") BigDecimal minim);

    @Modifying
    @Query("update Lloguer l set l.estat = 'CADUCAT' where l.fi is null " +
           "and l.inici < :limit")
    int caducarAbandonats(@Param("limit") Instant limit);
}

Solucions

Solució 1.

  1. Consulta derivada. Dues condicions simples sobre propietats directes: List<Bicicleta> findByEstacioIdAndEstat(Long estacioId, EstatBicicleta estat);

  2. Consulta nativa amb projecció per interfície. JPQL no té funcions trigonomètriques, així que la fórmula del semivers només és expressable en SQL: List<EstacioProperaProjeccio> cercarMesProperes(...) amb nativeQuery = true.

  3. Specification. Quatre filtres opcionals són 16 combinacions, i cap altra tècnica no ho cobreix sense duplicar codi: n'hi ha prou amb el Page<Estacio> findAll(Specification<Estacio>, Pageable) heretat de JpaSpecificationExecutor.

  4. @Query JPQL amb select new. És una agregació amb group by, impossible en una consulta derivada, i no necessita entitats:

@Query("""
       select new com.ciclourbana.lloguers.dto.LloguersPerEstacio(
              l.estacioOrigen.id, l.estacioOrigen.nom, count(l))
         from Lloguer l
        where l.inici >= :desDe
        group by l.estacioOrigen.id, l.estacioOrigen.nom
        order by count(l) desc
       """)
List<LloguersPerEstacio> comptarPerEstacioOrigen(@Param("desDe") Instant desDe);
  1. @Modifying. Operació massiva sobre moltes files: carregar-les totes per modificar-les seria absurd:
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
       update Bicicleta b
          set b.estat = com.ciclourbana.bicicletes.EstatBicicleta.MANTENIMENT
        where b.nivellBateria < :llindar
          and b.estat = com.ciclourbana.bicicletes.EstatBicicleta.DISPONIBLE
       """)
int marcarPerManteniment(@Param("llindar") int llindar);

Solució 2.

package com.ciclourbana.lloguers.dto;

public record FacturacioUsuari(Long usuariId, String nom, String correu,
                               long nombreLloguers, long minutsTotals,
                               BigDecimal importTotal) { }
@Query(value = """
               select new com.ciclourbana.lloguers.dto.FacturacioUsuari(
                      u.id, u.nom, u.correu,
                      count(l.id),
                      sum(function('extract', epoch from (l.fi - l.inici))) / 60,
                      sum(l.importTotal))
                 from Lloguer l
                 join l.usuari u
                where l.fi is not null
                  and l.inici >= :desDe
                  and l.inici < :finsA
                group by u.id, u.nom, u.correu
               having sum(l.importTotal) > 0
                order by sum(l.importTotal) desc
               """,
       countQuery = """
                    select count(distinct l.usuari.id) from Lloguer l
                     where l.fi is not null
                       and l.inici >= :desDe and l.inici < :finsA
                    """)
Page<FacturacioUsuari> facturacioDelPeriode(@Param("desDe") Instant desDe,
                                            @Param("finsA") Instant finsA,
                                            Pageable pageable);

Decisions i per què:

  • select new amb record: el resultat és directament el DTO de resposta, sense carregar ni un Usuari ni un Lloguer com a entitat. Amb milers de lloguers, la diferència de memòria és d'ordres de magnitud.
  • countQuery declarat: amb group by, la derivació automàtica comptaria files agrupades i donaria un nombre de pàgines incorrecte. count(distinct l.usuari.id) és el real.
  • join l.usuari u en lloc de navegar l.usuari.nom repetidament: un sol JOIN explícit, més llegible i amb un sol àlies al group by.
  • >= :desDe and < :finsA en comptes de between, que és inclusiu en tots dos extrems i duplicaria l'últim instant entre dos mesos consecutius: l'interval semiobert és el correcte per a rangs de dates.
  • function('extract', ...) invoca una funció del motor des de JPQL, amb la dependència de PostgreSQL que això implica; l'alternativa portable seria desar la durada en minuts com a columna en finalitzar el lloguer, evitant a més el càlcul a cada informe.

Solució 3. Els quatre problemes:

1. Page amb JOIN FETCH sense countQuery. Encara que aquí són associacions @ManyToOne i no col·leccions —cosa que evita l'avís HHH90003004—, Spring Data intentarà derivar el recompte d'una consulta amb fetch i fallarà o generarà un count amb JOIN innecessaris. Cal declarar-lo.

2. Paràmetres posicionals. ?1 i ?2 són fràgils: intercanviar desDe i finsA a la signatura no dona error de compilació i produeix una consulta que no retorna mai res. Fes servir @Param.

3. La consulta nativa retorna entitats amb SELECT *. Funciona mentre les columnes coincideixin, es trenca en silenci en afegir-ne o reanomenar-ne una, i no es valida en arrencar. A més no cal que sigui nativa: importTotal > :minim és JPQL pur.

4. @Modifying sense clearAutomatically ni flushAutomatically, i amb un literal d'enumerat. El context queda amb lloguers obsolets, i 'CADUCAT' entre cometes és un literal de cadena que Hibernate pot no convertir al tipus enumerat. Falta a més @Transactional. Versió corregida:

public interface LloguerRepositori extends JpaRepository<Lloguer, Long> {

    @Query(value = "select l from Lloguer l join fetch l.usuari join fetch l.bicicleta "
                 + "where l.inici >= :desDe and l.inici < :finsA",
           countQuery = "select count(l) from Lloguer l "
                      + "where l.inici >= :desDe and l.inici < :finsA")
    Page<Lloguer> cercarPerPeriode(@Param("desDe") Instant desDe,
                                   @Param("finsA") Instant finsA, Pageable pageable);

    @Query("select l from Lloguer l where l.importTotal > :minim")
    List<Lloguer> cercarCars(@Param("minim") BigDecimal minim);

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Transactional
    @Query("""
           update Lloguer l
              set l.estat = com.ciclourbana.lloguers.EstatLloguer.CADUCAT
            where l.fi is null and l.inici < :limit
           """)
    int caducarAbandonats(@Param("limit") Instant limit);
}

Conclusió

CicloUrbana ja sap preguntar. Domines les consultes derivades del nom del mètode, entenent com s'analitza el nom en subjecte i predicat, la taula completa de paraules clau i —el més valuós— que l'error apareix en arrencar amb un PropertyReferenceException que fins i tot suggereix el nom correcte. Saps navegar propietats imbricades, desambiguar amb _ quan cal i reconèixer els senyals que indiquen que el nom del mètode s'ha quedat curt: més de tres condicions, barreja d'And i Or, agregacions, subconsultes o filtres opcionals. Escrius JPQL amb @Query sobre entitats i propietats en lloc de taules i columnes, amb paràmetres nomenats i blocs de text, declarant el countQuery quan la consulta paginada és complexa. I projectes a DTO amb select new, la tècnica de més impacte de la lliçó: una sola consulta, quatre columnes, cap entitat gestionada i el DTO de resposta construït directament.

Resols el N+1 de 04-04 amb JOIN FETCH, coneixent els seus dos límits —no paginar col·leccions, una sola col·lecció per consulta— i la seva alternativa declarativa @EntityGraph, amb o sense @NamedEntityGraph. Recorres a consultes natives només quan JPQL no abasta, com la fórmula del semivers de les estacions més properes, assumint conscientment els seus riscos de portabilitat i de validació tardana. Manegues @Modifying per a operacions massives sabent per què clearAutomatically i flushAutomatically no són opcionals: una consulta de modificació se salta el context de persistència, l'auditoria, les cascades i el bloqueig optimista. Tries entre projeccions per interfície tancada, oberta amb SpEL, per record i dinàmiques amb genèrics, sabent quina optimitza el SELECT i quina no. I construeixes el cercador d'estacions de Ribalta amb Specification, on retornar null per a un filtre absent permet que qualsevol combinació funcioni sense ni un if.

Queda una peça que ha aparegut a cada exemple sense explicar-se del tot: @Transactional. L'hem posada als serveis, l'hem necessitada a @Modifying, hem dit que el context de persistència viu el que dura la transacció, que el dirty checking desa sense cridar save() i que readOnly = true optimitza alguna cosa. Tot això són afirmacions que encara no hem justificat. La lliçó 04-07, Transaccions i Gestió de la Persistència, les justifica: què és una transacció i què signifiquen les propietats ACID quan iniciar un lloguer exigeix marcar la bicicleta i crear el registre junts o cap; on posar @Transactional i per què; com funciona per dins i els dos paranys que fan que de vegades no funcioni en absolut; els set valors de propagation i els quatre d'isolation; la regla del rollback i l'error clàssic de capturar-la i quedar-se'n sense; el bloqueig pessimista enfront de l'optimista de @Version en la cursa per l'última bicicleta d'una estació; i els esdeveniments transaccionals que permeten cobrar el lloguer després del commit sense retenir una connexió del pool.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats