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
- Consultes derivades del nom del mètode
- Taula exhaustiva de paraules clau
- Propietats imbricades i ambigüitats
- Quan abandonar el nom del mètode
@Queryamb JPQL- Projeccions a DTO a la consulta
JOIN FETCHi la resolució del N+1- Consultes natives
@Modifying:UPDATEiDELETE- Projeccions per interfície i per
record @EntityGraphSpecificationi l'API Criteria@NamedQuery,StreamableiStream- Verificar el SQL realment executat
- Errors Comuns i Consells
- Exercicis
- 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.
- 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.
- 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 correuSpring Data genera un JOIN automàticament:
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.
- 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.
@Query amb JPQL
@Query amb JPQLJPQL (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.
- 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.
JOIN FETCH i la resolució del N+1
JOIN FETCH i la resolució del N+1Quan 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.
- 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ó.
@Modifying: UPDATE i DELETE
@Modifying: UPDATE i DELETEPer 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.
- Projeccions per interfície i per
record
recordUna 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 |
@EntityGraph
@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 consultaGenera 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.
Specification i l'API Criteria
Specification i l'API CriteriaEl 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).
@NamedQuery, Streamable i Stream
@NamedQuery, Streamable i StreamConsultes 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.
- 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: trueI 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.
- Bicicletes d'una estació amb estat
DISPONIBLE. - Les 10 estacions més properes a una coordenada, amb la distància en quilòmetres.
- Cercador amb quatre filtres opcionals combinables i paginació.
- Recompte de lloguers per estació d'origen de l'últim mes, per a un informe.
- Totes les bicicletes amb bateria inferior al llindar, passades a
MANTENIMENTen 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.
-
Consulta derivada. Dues condicions simples sobre propietats directes:
List<Bicicleta> findByEstacioIdAndEstat(Long estacioId, EstatBicicleta estat); -
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(...)ambnativeQuery = true. -
Specification. Quatre filtres opcionals són 16 combinacions, i cap altra tècnica no ho cobreix sense duplicar codi: n'hi ha prou amb elPage<Estacio> findAll(Specification<Estacio>, Pageable)heretat deJpaSpecificationExecutor. -
@QueryJPQL ambselect new. És una agregació ambgroup 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);@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 newambrecord: el resultat és directament el DTO de resposta, sense carregar ni unUsuarini unLloguercom a entitat. Amb milers de lloguers, la diferència de memòria és d'ordres de magnitud.countQuerydeclarat: ambgroup 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 uen lloc de navegarl.usuari.nomrepetidament: un solJOINexplícit, més llegible i amb un sol àlies algroup by.>= :desDe and < :finsAen comptes debetween, 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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
