Niveau : Intermédiaire – Avancé Technologies : Spring Boot 3, Spring Data JPA, Hibernate, PostgreSQL, Maven
@Query
La classe DataInitializer est fournie en fin de fichier.
Vous travaillez pour une association environnementale qui souhaite motiver ses membres à adopter des comportements écologiques. Elle vous demande de développer EcoTrack, une application de suivi des actions écologiques.
Chaque membre peut enregistrer ses actions du quotidien (planter un arbre, utiliser les transports en commun, composter ses déchets…). Chaque action rapporte des points d’impact selon sa catégorie. L’association veut pouvoir suivre les performances de ses membres, établir des classements et générer des statistiques.
Utilisateur
Enum NiveauEco : DEBUTANT, INTERMEDIAIRE, EXPERT, CHAMPION
NiveauEco
DEBUTANT
INTERMEDIAIRE
EXPERT
CHAMPION
CategorieAction
Action
ActionUtilisateur
Badge
Relation : Un Utilisateur peut avoir plusieurs Badge (ManyToMany, table de jointure à préciser : utilisateur_badge)
utilisateur_badge
Créez un projet Spring Boot avec Spring Initializr (start.spring.io) avec les dépendances suivantes :
Configurez application.yml pour vous connecter à votre base PostgreSQL locale. Créez une base de données nommée ecotrack. Pensez à modifier le username et le mot de passe, ainsi que le port du server.
application.yml
ecotrack
spring: datasource: url: jdbc:postgresql://localhost:5432/ecotrack username: postgres password: test driver-class-name: org.postgresql.Driver jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: create-drop show-sql: true properties: hibernate: format_sql: true logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE server: port: 8887
Créez toutes les entités Java décrites dans le modèle de données ci-dessus. N’oubliez pas :
@Entity
@Table
@Column
@ManyToOne
@OneToMany
@ManyToMany
LAZY
Créez un composant DataInitializer annoté avec @Component qui implémente CommandLineRunner et insère des données de test au démarrage :
DataInitializer
@Component
CommandLineRunner
Pas besoin d’utiliser new pour initialiser le DataInitializer, Spring Boot s’en charge pour vous. Une fois les entités créées, la méthode run() de CommandLineRunner se charge de faire les insertions en base de données.
run()
Créez les interfaces Repository pour chaque entité et implémentez les méthodes ci-dessous.
UtilisateurRepository
Implémentez les méthodes dérivées suivantes (sans @Query) :
// 1. Trouver un utilisateur par son pseudo (exact) Optional<Utilisateur> findByPseudo(String pseudo); // 2. Trouver les utilisateurs d'une ville donnée List<Utilisateur> findByVille(String ville); // 3. Trouver les utilisateurs par niveau List<Utilisateur> findByNiveauEco(NiveauEco niveau); // 4. Vérifier si un email existe déjà boolean existsByEmail(String email); // 5. Trouver les utilisateurs inscrits après une date List<Utilisateur> findByDateInscriptionAfter(LocalDate date); // 6. Trouver les utilisateurs dont le pseudo contient un mot-clé List<Utilisateur> findByPseudoContainingIgnoreCase(String motCle); // 7. Compter les utilisateurs par niveau Long countByNiveauEco(NiveauEco niveau); // 8. Trouver les utilisateurs d'une ville et d'un niveau donnés, triés par pseudo List<Utilisateur> findByVilleAndNiveauEcoOrderByPseudoAsc(String ville, NiveauEco niveau);
ActionRepository
// 1. Trouver les actions par catégorie List<Action> findByCategorieId(Long categorieId); // 2. Trouver les actions dont les points sont supérieurs à X List<Action> findByPointsImpactGreaterThan(Integer points); // 3. Trouver les actions dont les points sont entre X et Y, triées par points décroissants List<Action> findByPointsImpactBetweenOrderByPointsImpactDesc(Integer min, Integer max); // 4. Trouver les actions par nom de catégorie (navigation) List<Action> findByCategorieNom(String nomCategorie); // 5. Top 5 des actions les plus rémunératrices List<Action> findTop5ByOrderByPointsImpactDesc();
ActionUtilisateurRepository
// 1. Trouver toutes les actions d'un utilisateur, triées par date décroissante List<ActionUtilisateur> findByUtilisateurIdOrderByDateRealisationDesc(Long utilisateurId); // 2. Trouver les actions d'un utilisateur sur une période donnée List<ActionUtilisateur> findByUtilisateurIdAndDateRealisationBetween( Long utilisateurId, LocalDate debut, LocalDate fin ); // 3. Compter le nombre d'actions réalisées par un utilisateur Long countByUtilisateurId(Long utilisateurId); // 4. Trouver les actions valides d'une catégorie pour un utilisateur List<ActionUtilisateur> findByUtilisateurIdAndActionCategorieIdAndValideTrue( Long utilisateurId, Long categorieId );
Dans UtilisateurRepository, ajoutez avec @Query :
// 1. Calculer le score total d'un utilisateur // Score = SUM(action.pointsImpact * actionUtilisateur.quantite) // Ne compter que les actions valides @Query("SELECT SUM(a.pointsImpact * au.quantite) " + "FROM ActionUtilisateur au JOIN au.action a " + "WHERE au.utilisateur.id = :utilisateurId AND au.valide = true") Long calculerScoreUtilisateur(@Param("utilisateurId") Long utilisateurId); // 2. Classement général des utilisateurs par score décroissant // Retourner : pseudo, ville, score total // Indice : GROUP BY avec SUM @Query("SELECT u.pseudo, u.ville, SUM(a.pointsImpact * au.quantite) as score " + "FROM Utilisateur u " + "LEFT JOIN u.actionsRealisees au " + "LEFT JOIN au.action a " + "WHERE au.valide = true OR au IS NULL " + "GROUP BY u.id, u.pseudo, u.ville " + "ORDER BY score DESC NULLS LAST") List<Object[]> classementGeneral(); // 3. Top 10 des utilisateurs du mois en cours @Query("SELECT u.pseudo, SUM(a.pointsImpact * au.quantite) as score " + "FROM ActionUtilisateur au " + "JOIN au.utilisateur u " + "JOIN au.action a " + "WHERE MONTH(au.dateRealisation) = MONTH(CURRENT_DATE) " + "AND YEAR(au.dateRealisation) = YEAR(CURRENT_DATE) " + "AND au.valide = true " + "GROUP BY u.id, u.pseudo " + "ORDER BY score DESC") List<Object[]> top10DuMois(Pageable pageable); // Passez PageRequest.of(0, 10) // 4. Utilisateurs n'ayant réalisé aucune action depuis X jours @Query("SELECT u FROM Utilisateur u WHERE NOT EXISTS (" + " SELECT au FROM ActionUtilisateur au " + " WHERE au.utilisateur = u " + " AND au.dateRealisation >= :dateLimit" + ")") List<Utilisateur> findInactifDepuis(@Param("dateLimit") LocalDate dateLimit);
Dans ActionUtilisateurRepository, ajoutez :
// 1. Nombre d'actions réalisées par catégorie (pour un utilisateur) @Query("SELECT c.nom, c.icone, COUNT(au), SUM(a.pointsImpact * au.quantite) " + "FROM ActionUtilisateur au " + "JOIN au.action a " + "JOIN a.categorie c " + "WHERE au.utilisateur.id = :utilisateurId AND au.valide = true " + "GROUP BY c.id, c.nom, c.icone " + "ORDER BY SUM(a.pointsImpact * au.quantite) DESC") List<Object[]> statsParCategoriePourUtilisateur(@Param("utilisateurId") Long id); // 2. Actions les plus populaires (les plus souvent réalisées) @Query("SELECT a.nom, COUNT(au) as nbRealisation, SUM(au.quantite) as totalQuantite " + "FROM ActionUtilisateur au JOIN au.action a " + "WHERE au.valide = true " + "GROUP BY a.id, a.nom " + "ORDER BY nbRealisation DESC") List<Object[]> actionsLesPlusPopulaires(Pageable pageable); // 3. Évolution du score d'un utilisateur semaine par semaine // Retourner : semaine (numéro), année, points gagnés cette semaine @Query(value = "SELECT EXTRACT(WEEK FROM date_realisation) as semaine, " + "EXTRACT(YEAR FROM date_realisation) as annee, " + "SUM(a.points_impact * au.quantite) as points " + "FROM action_utilisateur au " + "JOIN action a ON au.action_id = a.id " + "WHERE au.utilisateur_id = :utilisateurId " + "AND au.valide = true " + "GROUP BY semaine, annee " + "ORDER BY annee, semaine", nativeQuery = true) List<Object[]> evolutionHebdomadaire(@Param("utilisateurId") Long utilisateurId); // 4. Score total de l'application (pour afficher sur le dashboard) @Query("SELECT SUM(a.pointsImpact * au.quantite) FROM ActionUtilisateur au " + "JOIN au.action a WHERE au.valide = true") Long scoreTotalCommunaute();
// Dans ActionUtilisateurRepository : // 1. Invalider toutes les actions d'un utilisateur pour une date donnée @Modifying @Transactional @Query("UPDATE ActionUtilisateur au SET au.valide = false " + "WHERE au.utilisateur.id = :utilisateurId " + "AND au.dateRealisation = :date") int invaliderActionsJour( @Param("utilisateurId") Long utilisateurId, @Param("date") LocalDate date ); // 2. Supprimer les actions invalides de plus de 1 an @Modifying @Transactional @Query("DELETE FROM ActionUtilisateur au " + "WHERE au.valide = false " + "AND au.dateRealisation < :dateLimit") int supprimerAnciennesActionsInvalides(@Param("dateLimit") LocalDate dateLimit);
On parle bien d’interfaces, pas de classe ni de record.
interfaces
// projection pour l'affichage d'une carte utilisateur public interface UtilisateurCard { String getPseudo(); String getVille(); NiveauEco getNiveauEco(); LocalDate getDateInscription(); } // projection pour un résumé d'action public interface ActionResume { String getNom(); Integer getPointsImpact(); CategorieResume getCategorie(); interface CategorieResume { String getNom(); String getIcone(); } }
Ajoutez les méthodes correspondantes dans les repositories :
// Dans UtilisateurRepository List<UtilisateurCard> findByVille(String ville); // (avec projection) <T> List<T> findByNiveauEco(NiveauEco niveau, Class<T> type); // Dans ActionRepository List<ActionResume> findByCategorieNom(String nomCategorie);
// DTO pour le tableau de bord d'un utilisateur public record UtilisateurDashboardDTO( Long id, String pseudo, String ville, NiveauEco niveau, Long nombreActionsTotal, Long scoreTotal ) {} // DTO pour les statistiques par catégorie public record StatsCategorieDTO( String nomCategorie, String icone, Long nombreActions, Long pointsTotal ) {}
Dans les repositories, ajoutez des méthodes @Query utilisant NEW :
NEW
// Dans UtilisateurRepository @Query("SELECT NEW com.ecotrack.dto.UtilisateurDashboardDTO(" + " u.id, u.pseudo, u.ville, u.niveauEco, " + " COUNT(au), COALESCE(SUM(a.pointsImpact * au.quantite), 0)" + ") " + "FROM Utilisateur u " + "LEFT JOIN u.actionsRealisees au " + "LEFT JOIN au.action a " + "WHERE (au.valide = true OR au IS NULL) " + "GROUP BY u.id, u.pseudo, u.ville, u.niveauEco") List<UtilisateurDashboardDTO> findAllDashboard();
// Dans UtilisateurRepository @Query( value = "SELECT u FROM Utilisateur u LEFT JOIN FETCH u.actionsRealisees", countQuery = "SELECT COUNT(u) FROM Utilisateur u" ) Page<Utilisateur> findAllAvecActions(Pageable pageable);
Créez un service ClassementService avec une méthode :
ClassementService
public Page<UtilisateurDashboardDTO> getClassementPagine(int page, int taille) { Pageable pageable = PageRequest.of(page, taille); // récupérer et retourner le classement paginé }
Créez une classe UtilisateurSpecifications avec des méthodes statiques ci-dessous.
UtilisateurSpecifications
Reprenez l’exemple dans la page de cours sur les Specifications pour Spring Data JPA (Lien vers le cours complet sur JPQL & Spring Data), voir le chapitre 13.4 Spring DataJPA Specifications
public class UtilisateurSpecifications { // Filtre par ville (optionnel) public static Specification<Utilisateur> villeEgale(String ville) { ... } // Filtre par niveau (optionnel) public static Specification<Utilisateur> niveauEco(NiveauEco niveau) { ... } // Filtre : inscrit après une date (optionnel) public static Specification<Utilisateur> inscritApres(LocalDate date) { ... } // Filtre : pseudo contient (optionnel) public static Specification<Utilisateur> pseudoContient(String motCle) { ... } }
Puis créez une classe de critères et un service de recherche :
public record RechercheUtilisateurCriteres( String pseudo, String ville, NiveauEco niveau, LocalDate inscritDepuis ) {} @Service public class RechercheUtilisateurService { public Page<Utilisateur> rechercher(RechercheUtilisateurCriteres criteres, Pageable pageable) { Specification<Utilisateur> spec = Specification .where(UtilisateurSpecifications.pseudoContient(criteres.pseudo())) .and(UtilisateurSpecifications.villeEgale(criteres.ville())) .and(UtilisateurSpecifications.niveauEco(criteres.niveau())) .and(UtilisateurSpecifications.inscritApres(criteres.inscritDepuis())); return utilisateurRepository.findAll(spec, pageable); } }
Si vous avez terminé les parties précédentes, voici des défis supplémentaires… mais je n’ai moi-même pas encore codé cette partie ;)
Créez un service BadgeService qui attribue automatiquement les badges en fonction du score d’un utilisateur :
BadgeService
@Service public class BadgeService { // Vérifier et attribuer les badges mérités après chaque action @Transactional public List<Badge> attribuerBadgesManquants(Long utilisateurId) { // 1. Calculer le score de l'utilisateur // 2. Trouver les badges dont les pointsRequis <= score // 3. Exclure les badges que l'utilisateur a déjà // 4. Attribuer les nouveaux badges // 5. Retourner la liste des nouveaux badges obtenus } }
Requête à implémenter dans BadgeRepository :
BadgeRepository
// Trouver les badges que l'utilisateur n'a pas encore mais qu'il mérite @Query("SELECT b FROM Badge b " + "WHERE b.pointsRequis <= :score " + "AND b NOT IN (" + " SELECT b2 FROM Utilisateur u JOIN u.badges b2 WHERE u.id = :utilisateurId" + ")") List<Badge> findBadgesAAttribuer( @Param("utilisateurId") Long utilisateurId, @Param("score") Long score );
Créez une méthode qui génère un rapport textuel de la semaine pour un utilisateur :
Basé sur le score, mettez à jour automatiquement le niveauEco des utilisateurs :
niveauEco
// Dans UtilisateurRepository @Modifying @Transactional @Query("UPDATE Utilisateur u SET u.niveauEco = " + "CASE " + " WHEN :score >= 2000 THEN com.ecotrack.model.NiveauEco.CHAMPION " + " WHEN :score >= 500 THEN com.ecotrack.model.NiveauEco.EXPERT " + " WHEN :score >= 100 THEN com.ecotrack.model.NiveauEco.INTERMEDIAIRE " + " ELSE com.ecotrack.model.NiveauEco.DEBUTANT " + "END " + "WHERE u.id = :utilisateurId") int mettreAJourNiveau(@Param("utilisateurId") Long utilisateurId, @Param("score") Long score);
JOIN FETCH
@EntityGraph
countQuery
@Modifying
EXTRACT
ILIKE
Voici le code d’initilisation des données pour tester :
package com.ecotrack.config; import java.time.LocalDate; import java.util.List; import java.util.Random; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.ecotrack.model.Action; import com.ecotrack.model.ActionUtilisateur; import com.ecotrack.model.Badge; import com.ecotrack.model.CategorieAction; import com.ecotrack.model.NiveauEco; import com.ecotrack.model.Utilisateur; import com.ecotrack.repository.ActionRepository; import com.ecotrack.repository.ActionUtilisateurRepository; import com.ecotrack.repository.BadgeRepository; import com.ecotrack.repository.CategorieActionRepository; import com.ecotrack.repository.UtilisateurRepository; @Component public class DataInitializer implements CommandLineRunner { private final CategorieActionRepository categorieRepo; private final ActionRepository actionRepo; private final UtilisateurRepository utilisateurRepo; private final ActionUtilisateurRepository auRepo; private final BadgeRepository badgeRepo; public DataInitializer(CategorieActionRepository categorieRepo, ActionRepository actionRepo, UtilisateurRepository utilisateurRepo, ActionUtilisateurRepository auRepo, BadgeRepository badgeRepo) { this.categorieRepo = categorieRepo; this.actionRepo = actionRepo; this.utilisateurRepo = utilisateurRepo; this.auRepo = auRepo; this.badgeRepo = badgeRepo; } @Override @Transactional public void run(String... args) { // Catégories avec icones récupérées sur le web CategorieAction mobilite = categorieRepo.save(new CategorieAction("Mobilité", "Transport écologique", 10, "🚲")); CategorieAction alimentation = categorieRepo.save(new CategorieAction("Alimentation", "Manger responsable", 15, "🥗")); CategorieAction energie = categorieRepo.save(new CategorieAction("Énergie", "Réduire sa consommation", 12, "💡")); CategorieAction nature = categorieRepo.save(new CategorieAction("Nature", "Préserver la biodiversité", 20, "🌳")); CategorieAction dechets = categorieRepo.save(new CategorieAction("Déchets", "Réduire et recycler", 8, "♻️")); // Actions (vous pouvez créer les vôtres si besoin) Action velo = actionRepo.save(new Action("Trajet à vélo", "Kms parcourus à vélo", 10, null, mobilite)); Action covoiturage = actionRepo.save(new Action("Covoiturage", "Trajets en covoiturage", 15, 3, mobilite)); Action transportsEnCommun = actionRepo.save(new Action("Transport en commun", "Trajets TC", 8, null, mobilite)); Action repasVege = actionRepo.save(new Action("Repas végétarien", "Repas sans viande", 20, 3, alimentation)); Action marcheLocal = actionRepo.save(new Action("Marché local", "Achats producteurs locaux", 15, 1, alimentation)); Action compost = actionRepo.save(new Action("Compostage", "Kg compostés", 12, null, alimentation)); Action veille = actionRepo.save(new Action("Veille électronique", "Appareils mis en veille", 5, 1, energie)); Action led = actionRepo.save(new Action("Ampoule LED", "Ampoules remplacées", 30, null, energie)); Action doucheCourte = actionRepo.save(new Action("Douche courte", "Douches inférieures à 5 min", 8, 2, energie)); Action planterArbre = actionRepo.save(new Action("Planter un arbre", "Arbres plantés", 100, null, nature)); Action ramasserDechets = actionRepo.save(new Action("Ramasser des déchets", "Kg ramassés", 30, null, nature)); Action jardinBio = actionRepo.save(new Action("Jardinage bio", "Heures de jardinage bio", 20, null, nature)); Action trier = actionRepo.save(new Action("Tri sélectif", "Poubelles triées", 10, 1, dechets)); Action reparation = actionRepo.save(new Action("Réparation objet", "Objets réparés", 25, null, dechets)); Action zerodechet = actionRepo.save(new Action("Achat zéro déchet", "Achats sans emballage", 15, null, dechets)); // badges avec icones récupérées sur le web badgeRepo.save(new Badge("Première pousse", "Première action enregistrée", 1, "🌱")); badgeRepo.save(new Badge("Éco-débutant", "100 points atteints", 100, "🥉")); badgeRepo.save(new Badge("Éco-acteur", "500 points atteints", 500, "🥈")); badgeRepo.save(new Badge("Éco-champion", "2000 points atteints", 2000, "🥇")); badgeRepo.save(new Badge("Forêt vivante", "10 arbres plantés", 1000, "🌲")); badgeRepo.save(new Badge("Cycliste engagé", "500 km à vélo", 500, "🚴")); // Utilisateurs List<Utilisateur> utilisateurs = utilisateurRepo.saveAll(List.of( new Utilisateur("Feuillage42 ", "feuillage42@ca.com", "Paris", LocalDate.now().minusDays(120), NiveauEco.EXPERT), new Utilisateur("ÉcoGuerrier ", "ecoguerrier@ca.com", "Lyon", LocalDate.now().minusDays(200), NiveauEco.CHAMPION), new Utilisateur("NatureAmour", "natureamour@ca.com", "Bordeaux", LocalDate.now().minusDays(60), NiveauEco.INTERMEDIAIRE), new Utilisateur("VéloPassion", "velopassion@ca.com", "Paris", LocalDate.now().minusDays(90), NiveauEco.EXPERT), new Utilisateur("ZéroDéchet", "zerodechet@ca.com", "Nantes", LocalDate.now().minusDays(300), NiveauEco.CHAMPION), new Utilisateur("SuperVégé", "supervege@ca.com", "Toulouse", LocalDate.now().minusDays(45), NiveauEco.INTERMEDIAIRE), new Utilisateur("SolarPunk", "solarpunk@ca.com", "Rennes", LocalDate.now().minusDays(10), NiveauEco.DEBUTANT), new Utilisateur("ÉcoloCâlin", "ecolocalin@ca.com", "Strasbourg", LocalDate.now().minusDays(150), NiveauEco.EXPERT), new Utilisateur("OcéanPropre ", "oceanpropre@ca.com", "Marseille", LocalDate.now().minusDays(80), NiveauEco.INTERMEDIAIRE), new Utilisateur("VilleVerte ", "villeverte@ca.com", "Mer", LocalDate.now().minusDays(5), NiveauEco.DEBUTANT) )); // liste des actions réalisées (données de test variées) List<Action> allActions = List.of(velo, covoiturage, repasVege, planterArbre, trier, reparation, jardinBio, ramasserDechets, zerodechet, veille); Random random = new Random(42); for (Utilisateur utilisateur : utilisateurs) { int nbActions = 5 + random.nextInt(15); for (int i = 0; i < nbActions; i++) { Action action = allActions.get(random.nextInt(allActions.size())); LocalDate date = LocalDate.now().minusDays(random.nextInt(90)); int quantite = 1 + random.nextInt(5); auRepo.save(new ActionUtilisateur(utilisateur, action, date, quantite, null)); } } System.out.println("Données EcoTrack initialisées avec succès... enfin j'espère !"); } }
Bon courage et pensez à la planète !