SQL – Java & Moi https://javaetmoi.com Développeur Java, Spring & co, et fier de l'être Mon, 16 Jun 2025 06:57:01 +0000 fr-FR hourly 1 https://wordpress.org/?v=6.9.4 https://javaetmoi.com/wp-content/uploads/2022/05/cropped-java-icon-32x32.png SQL – Java & Moi https://javaetmoi.com 32 32 De Spring Data JPA à jOOQ https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/ https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/#respond Mon, 16 Jun 2025 06:57:01 +0000 https://javaetmoi.com/?p=2580 Continuer la lecture de De Spring Data JPA à jOOQ ]]>
jOOQ: The easiest way to write SQL in Java

Lors de la conférence Devoxx France 2025, j’ai participé à un hands-on lab de 2h intitulé Sortir des ORMs avec jOOQ. Acronyme de « Java Object Oriented Querying », jOOQ se présente comme une alternative à JPA permettant d’écrire des requêtes SQL en Java via une fluent API. Animé par Sylvain Decout et Samuel Lefebvre, cet atelier visait à migrer une application Spring Boot / JPA vers jOOQ à l’aide du starter Spring Boot pour jOOQ. Pour les curieux, le repo de l’atelier est disponible sur Github : jooq-handson.

Fort de cette découverte, je me suis à mon tour prêté à l’exercice de migrer vers jOOQ la couche de persistance Spring Data JPA de l’application démo Spring Petclinic. Un nouveau fork est né : spring-petclinic-jooq. Bienvenue à ce dernier dans la communauté Spring Petclinic.

L’usage de jOOQ se rapproche de l’utilisation de JdbcTemplate. Le développeur maitrise le nombre de requêtes envoyées à la base de données relationnelle. Ce qui les différencie, c’est la syntaxe : pas de SQL, mais une API Java fluide et type-safe spécifique à jOOQ qu’il va falloir appréhender. Rassurez-vous, cette API se rapproche du SQL : on y retrouve les mots clés select, update, insertInto, where, from, join, on, as … A ceux-ci, on ajoute des mots clés spécifiques à jOOQ : paginate, fetch, convertFrom … La documentation de jOOQ est très complète. On y apprend comment écrire des requêtes complexes à base de window function ou de Common Table Expressions (CTE) et comment utiliser des fonctionnalités avancées de SQL que peu de frameworks ORM supportent nativement : JSON functions, PIVOT, MERGE, UNION

Cet article a pour objectif d’expliquer les étapes adoptées pour migrer l’implémentation Spring Data JPA des repository vers jOOQ. Des exemples de code avant / après y sont proposés.

Configuration du build

Spring Boot supporte nativement l’usage des versions commerciales et Open Source de jOOQ. Dans le pom.xml ou le fichier build.gradle, commencer par déclarer le starter Spring Boot pour jOOQ spring-boot-starter-jooq :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jooq</artifactId>
</dependency>

L’étape suivante consiste à générer les classes Java à partir du schéma de la base de données. jOOQs propose différentes possibilités : à partir du script DDL de création du schéma comme sur Petclinic, de scripts Liquibase ou bien encore des méta-données d’une base existante.
Les plugins jooq-codegen-maven ou jooq-codegen-gradle sont à configurer.
Voici un exemple extrait de jOOQ Spring Petclinic :

<plugin>
  <groupId>org.jooq</groupId>
  <artifactId>jooq-codegen-maven</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <generator>
      <database>
        <name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
        <properties>
          <property>
            <key>scripts</key>
            <value>src/main/resources/db/h2/schema.sql</value>
          </property>
          <property>
            <key>sort</key>
            <value>semantic</value>
          </property>
          <property>
            <key>unqualifiedSchema</key>
            <value>none</value>
          </property>
          <property>
            <key>defaultNameCase</key>
            <value>as_is</value>
          </property>
        </properties>
      </database>
    </generator>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.jooq</groupId>
      <artifactId>jooq-meta-extensions</artifactId>
      <version>${jooq-meta-extensions.version}</version>
    </dependency>
  </dependencies>
</plugin>

La classe org.jooq.meta.extensions.ddl.DDLDatabase provenant de l’extension jooq-meta-extensions permet au plugin jooq-codegen-maven d’exploiter le script DDL src/main/resources/db/h2/schema.sql utilisé par défaut lors du démarrage de l’application avec le profil Spring par défaut.

Dans le package org.jooq.generated.tables, l’exécution du plugin jOOQ génère une classe Vets héritant de la classe TableImpl<VetsRecord> et modélisant la table éponyme. La classe VetsRecord a également été générée dans le sous-package records. Elle représente une ligne de la table pets.
Nous verrons leurs usages lors de la migration de la classe PetRepository.

Migration des repositories

L’une des forces de jOOQ est qu’il sait cohabiter aux côtés de JPA. On peut donc migrer au fil de l’eau les respositories d’une application et même choisir de conserver les 2 solutions en fonction des besoins. Cette capacité a été pratique dans le travail de migration : chaque repository a été migré l’un après l’autre. L’application Petclinic est restée fonctionnelle tout du long.

Premier changement notable : la nature des repositories qui passent d’une interface héritant de l’interface Repository de Spring Data JPA à une classe concrète qu’on annote avec @Repository et dont le constructeur accepte une instance de DSLContext.

Avant :

public interface VetRepository extends Repository<Vet, Integer> {

Après:

@Repository
public class VetRepository {

    private final DSLContext dsl;

    public VetRepository(DSLContext dslContext) {
       this.dsl = dslContext;
    }

Continuons par changer l’implémentation d’une première requête SQL simple utilisant un SELECT.
Requête native SQL utilisée par Spring Data JPA :

@Query("SELECT ptype FROM PetType ptype ORDER BY ptype.name")
List<PetType> findPetTypes();

Implémentation équivalente avec jOOQ :

public List<PetType> findPetTypes() {
    return dsl
       .selectFrom(TYPES)
       .orderBy(TYPES.NAME)
       .fetchInto(PetType.class);
}

On retrouve ici tous les éléments de la requête SQL, mais avec une syntaxe Java jOOQ équivalente. Le mot clé SQL « SELECT » est remplacé par la méthode selectFrom(), le « ORDER BY » par la méthode orderBy(). A noter que nous n’utilisons pas de chaines de caractères pour nommer les tables et les colonnes, mais les constantes générées par le plugin jOOQ. Ainsi, en cas de changement de schéma (ex : nom de colonne renommée), le code Java ne compilera plus et il faudra l’adapter. Avec cette approche, les erreurs de syntaxe ne sont plus possibles. On perçoit ici toute la sécurité apportée par la type-safety de jOOQ.
Enfin, la méthode fetchInto() mappe les lignes retournées par la base dans une liste d’instance de PetType.

Là où JPA et Hibernate nous facilitaient la sauvegarde de nos entités JPA, jOOQ va demander un travail nettement plus important. En effet, la méthode save() de l’interface CrudRepository de Spring Data JPA ne demandait qu’à être appelée. La magie des ORM opérait grâce aux annotations JPA apposées sur les entités. jOOQ nécessite de prévoir les 2 requêtes SQL correspondantes et d’effectuer à la main le binding des propriétés du Owner vers les colonnes. Exemple de sauvegarde d’un Owner :

public Integer saveOrUpdateDetails(Owner owner) {
    if (owner.isNew()) {
       return requireNonNull(
          dsl.insertInto(OWNERS)
             .set(mapOwnerToRecord(owner))
             .returningResult(OWNERS.ID)
             .fetchOne())
          .getValue(OWNERS.ID);
    } else {
       dsl.update(OWNERS)
          .set(mapOwnerToRecord(owner))
          .where(OWNERS.ID.eq(owner.getId()))
          .execute();
       return owner.getId();
    }
}

private Map<Field<?>, Object> mapOwnerToRecord(Owner owner) {
    return Map.of(OWNERS.FIRST_NAME, owner.getFirstName(), OWNERS.LAST_NAME, owner.getLastName(), OWNERS.ADDRESS,
       owner.getAddress(), OWNERS.CITY, owner.getCity(), OWNERS.TELEPHONE, owner.getTelephone());
}

Rien de compliqué, mais un peu plus verbeux.
L’un des principaux avantages de jOOQ consiste à maitriser le nombre de requêtes SQL envoyées à la base. En l’occurrence, dans cet exemple, une seule et unique requête de type UPDATE est envoyée lors d’un mise à jour.
Avec l’implémentation Spring Data JPA, dans le cadre de la mise à jour d’un Owner, comme expliqué dans l’article Skip Select Before Insert in Spring Data JPA, Spring Data JPA appelle la méthode merge() de l’entity manager JPA qui, si l’entité n’est pas en cache, va charger le Owner en exécutant autant de requêtes SQL de type SELECT que nécessaires.

Autre différence notable : jOOQ laisse décider du ou des champs à mettre à jour. Dans notre exemple, les Pets associés à leur Owner ne seront par exemple pas mis à jour. Avec JPA, on utilisait le cascading et l’attribut cascade des annotations comme @OneToMany.

Dans la même idée, lors de requêtes de type SELECT, les jointures entre tables devront être systématiquement précisées. A titre d’exemple, charger un animal et son type nécessitera un appel à join() :

@Transactional(readOnly = true)
public Optional<Pet> findByIdWithoutVisits(Integer petId) {
    return dsl.select()
       .from(PETS)
       .join(PETS.types_())
       .where(PETS.ID.eq(petId))
       .fetchOptional(PetRepository::toPet);
}

Noter ici l’utilisation d’une jointure implicite basée sur la clé étrangère, évitant ainsi d’ajouter une clause ON entre la PK de PETS et la FK de TYPES.
Avec une association 1:1, le chargement et le mapping du type d’animal ne présente aucune difficulté :

private static Pet toPet(org.jooq.Record row) {
    return new Pet(row.get(PETS.ID), row.get(PETS.NAME), row.get(PETS.BIRTH_DATE),
          new PetType(row.get(PETS.TYPE_ID), row.get(TYPES.NAME)));
}

Le chargement des associations 1:N se complexifie. L’usage de l’opérateur mulstiset() du SQL qui est supporté par jOOQ permet de charger les Vets et leurs Specialities en une seule requête :

public List<Vet> findAll(){
    return dsl.select(VETS.ID, VETS.FIRST_NAME, VETS.LAST_NAME, MULTISET_SPECIALITIES)
       .from(VETS)
       .leftJoin(VETS.vetSpecialties())
       .orderBy(VETS.ID)
       .fetch(VetRepository::toVet);
}

private static final Field<List<Specialty>> MULTISET_SPECIALITIES = multiset(
    select(VET_SPECIALTIES.specialties().ID, VET_SPECIALTIES.specialties().NAME)
       .from(VET_SPECIALTIES)
       .where(VET_SPECIALTIES.VET_ID.eq(VETS.ID)))
    .as("specialties")
    .convertFrom(result -> result.map(it -> new Specialty(it.get(SPECIALTIES.ID), it.get(SPECIALTIES.NAME))));


private static Vet toVet(Record4<Integer, String, String, List<Specialty>> row) {
    return new Vet(row.get(VETS.ID), row.get(VETS.FIRST_NAME), row.get(VETS.LAST_NAME),
       new HashSet<>(row.get(MULTISET_SPECIALITIES)));
}

jOOQ permet d’imbriquer plusieurs multiset afin de charger les visites des animaux d’un propriétaire en une seule requête. Je vous renvoie à la classe OwnerRepository.

Pour finir, les écrans « Find Owners » et « Veterinarians » affichent les résultats de manière paginée. jOOQ supporte la pagination au travers de la Seek Method (aussi appelée Keyset paging) ou du calcul des méta-données de pagination en une seule requête SQL. C’est cette dernière approche qui a été utilisée sur jOOQ Petclinic afin de garder iso-fonctionnels les écrans paginés. Les plus curieux peuvent se référer à l’implémentation de la méthode findAll(Pageable pageable) de VetRepository et à la méthode paginate() du JooqHelper. Sur le même modèle que ce que propose Spring Data, des records Pageable et Page ont été introduits dans la base de code.

Au revoir JPA

Une fois l’ensemble des Repository migrés, la dernière étape a consisté à retirer la dépendance spring-boot-starter-data-jpa ainsi que toutes les annotations JPA apposées sur les entités (@Entity, @Table, @ ManyToMany …).

Débarrassé de JPA, nous pouvons revoir en partie le design de l’application qui avait été limité par ce dernier. En effet, les entités JPA ne peuvent pas être modélisées avec des record Java. Suite à la migration vers jOOQ, les entités du domaine métier de Spring Petclinic n’ont plus d’adhérence avec la couche de persistance. Les classes immutables ont pu être converties en record. Exemple du value object Speciality :

public record Specialty(Integer id, String name) {
}

Le refactoring de la modélisation aurait pu aller plus loin, mais ce n’était pas l’objectif de cette version de Petclinic dédiée à jOOQ et non à la Clean Architecture. Peut-être l’objet d’un prochain article ?

Conclusion

A travers l’exemple de migration de l’application démo Spring Petclinic, cet article donne un aperçu des possibilités offertes par jOOQ. Cette librairie est mature, a plus de 15 ans (la version 1.0.0 de jOOQ est sortie en 2010) et est utilisée par de grands comptes comme Apple, Allianz et Mastercard.
Notez néanmoins que jOOQ possède un système de double licence : commerciale et Open Source. Les distributions commerciales de jOOQ maintiennent un support versionné des SGBDR. A contrario, l’édition Open Source de jOOQ ne supporte que la dernière version des SGBDR Open Source. A ce titre, l’utilisation de jOOQ avec Oracle et SQL Server requière une licence commerciale.

En replongeant dans le SQL, je me suis aperçu que j’étais passé à côté de certaines fonctionnalités avancées comme les MULTISET. A retenir.

Enfin, je remercie Sylvain pour sa relecture du code de Spring Petclinic jOOQ et ses conseils avisés. J’invite tous les autres experts jOOQ à venir améliorer le repository spring-petclinic-jooq en soumettant des Issues ou en proposant des Pull Requests.

]]>
https://javaetmoi.com/2025/06/de-spring-data-jpa-a-jooq/feed/ 0
Spring Batch s’auto-nettoie https://javaetmoi.com/2012/06/sprint-batch-sauto-nettoie/ https://javaetmoi.com/2012/06/sprint-batch-sauto-nettoie/#respond Tue, 26 Jun 2012 18:49:09 +0000 http://javaetmoi.com/?p=187 Continuer la lecture de Spring Batch s’auto-nettoie ]]> Lorsque vous mettez en œuvre Spring Batch pour réaliser des traitements par lots, vous avez le  choix d’utiliser une implémentation de JobRepository soit en mémoire soit persistante. L’avantage de cette dernière est triple :

  1. Conserver un historique des différentes exécutions de vos instances de jobs.
  2. Pouvoir suivre en temps réel le déroulement de votre batch via, par exemple, l’excellent Spring Batch Admin.
  3. Avoir la possibilité de reprendre un batch là où il s’était arrêté en erreur.

La contrepartie d’utiliser un JobRepository persistant est de devoir faire reposer le batch sur une base de données relationnelles. Le schéma sur lequel s’appuie Spring Bath est composé de 6 tables. Leur MPD est disponible dans l’annexe  B. Meta-Data Schema du manuel de référence de Spring Batch. SpringSource faisant bien les choses, les scripts DDL de différentes solutions du marché (ex : MySQL, Oracle, DB2, SQL Server, Postgres, H2 …) sont disponibles dans le package org.springframework.batch.core du JAR spring-batch-core-xxx.jar
Qui dit base de données, dit dimensionnement de cette dernière. L’espace disque requis est alors fonction du nombre d’exécutions estimé, de la nature des informations contextuelles persistées et de la durée de rétention des données. Cette démarche prend tout son sens lorsqu’une instance de base de données est dédiée au schéma de Spring Batch.  En faisant quelques hypothèses (ex : sur le taux d’échec) et en mesurant le volume occupé sur plusieurs exécutions des batchs, il est possible de prévoir assez finement l’espace occupé par les données.

A moins de disposer de ressources infinies ou de n’avoir qu’un seul batch annuel, il est fréquent de fixer une durée de rétention de l’historique. Première option : demander à l’équipe d’exploitation de régulièrement lancer un script SQL de purge. Deuxième option : utiliser Spring Batch pour purger ses propres données !!

Une Tasklet pour purger les données

De base, Spring Batch n’offre pas cette fonctionnalité. Et sur le Jira de SpringSource, je n’ai pas trouvé de demandes d’évolutions allant dans ce sens. Dans le ticket BATCH-1747, Lucas Ward, commiteur Spring Batch,  invite les personnes intéressées à passer par des requêtes SQL de suppression après désactivation des contraintes d’intégrité.

Partant de ce constat, je me suis lancé dans l’écriture d’une tasklet permettant de ne conserver l’historique Spring Batch des N derniers mois.  Surement perfectible, en voici le résultat :

public class RemoveSpringBatchHistoryTasklet implements Tasklet, InitializingBean {

    /**
     * SQL statements removing step and job executions compared to a given date.
     */
    private static final String  SQL_DELETE_BATCH_STEP_EXECUTION_CONTEXT = "DELETE FROM %PREFIX%STEP_EXECUTION_CONTEXT WHERE STEP_EXECUTION_ID IN (SELECT STEP_EXECUTION_ID FROM %PREFIX%STEP_EXECUTION WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM  %PREFIX%JOB_EXECUTION where CREATE_TIME < ?))";
    private static final String  SQL_DELETE_BATCH_STEP_EXECUTION         = "DELETE FROM %PREFIX%STEP_EXECUTION WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM %PREFIX%JOB_EXECUTION where CREATE_TIME < ?)";
    private static final String  SQL_DELETE_BATCH_JOB_EXECUTION_CONTEXT  = "DELETE FROM %PREFIX%JOB_EXECUTION_CONTEXT WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM  %PREFIX%JOB_EXECUTION where CREATE_TIME < ?)";
    private static final String  SQL_DELETE_BATCH_JOB_EXECUTION_PARAMS   = "DELETE FROM %PREFIX%JOB_EXECUTION_PARAMS WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM %PREFIX%JOB_EXECUTION where CREATE_TIME < ?)";
    private static final String  SQL_DELETE_BATCH_JOB_EXECUTION          = "DELETE FROM %PREFIX%JOB_EXECUTION where CREATE_TIME < ?";
    private static final String  SQL_DELETE_BATCH_JOB_INSTANCE           = "DELETE FROM %PREFIX%JOB_INSTANCE WHERE JOB_INSTANCE_ID NOT IN (SELECT JOB_INSTANCE_ID FROM %PREFIX%JOB_EXECUTION)";

    /**
     * Default value for the table prefix property.
     */
    private static final String  DEFAULT_TABLE_PREFIX                    = AbstractJdbcBatchMetadataDao.DEFAULT_TABLE_PREFIX;

    /**
     * Default value for the data retention (in month)
     */
    private static final Integer DEFAULT_RETENTION_MONTH                 = 6;

    private String               tablePrefix                             = DEFAULT_TABLE_PREFIX;

    private Integer              historicRetentionMonth                  = DEFAULT_RETENTION_MONTH;

    private JdbcTemplate         jdbcTemplate;

    private static final Logger  LOG                                     = LoggerFactory.getLogger(RemoveSpringBatchHistoryTasklet.class);

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
        int totalCount = 0;
        Date date = DateUtils.addMonths(new Date(), -historicRetentionMonth);
        DateFormat df = new SimpleDateFormat();
        LOG.info("Remove the Spring Batch history before the {}", df.format(date));

        int rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_STEP_EXECUTION_CONTEXT), date);
        LOG.info("Deleted rows number from the BATCH_STEP_EXECUTION_CONTEXT table: {}", rowCount);
        totalCount += rowCount;

        rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_STEP_EXECUTION), date);
        LOG.info("Deleted rows number from the BATCH_STEP_EXECUTION table: {}", rowCount);
        totalCount += rowCount;

        rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_JOB_EXECUTION_CONTEXT), date);
        LOG.info("Deleted rows number from the BATCH_JOB_EXECUTION_CONTEXT table: {}", rowCount);
        totalCount += rowCount;

        rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_JOB_EXECUTION_PARAMS), date);
        LOG.info("Deleted rows number from the BATCH_JOB_EXECUTION_PARAMS table: {}", rowCount);
        totalCount += rowCount;

        rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_JOB_EXECUTION), date);
        LOG.info("Deleted rows number from the BATCH_JOB_EXECUTION table: {}", rowCount);
        totalCount += rowCount;

        rowCount = jdbcTemplate.update(getQuery(SQL_DELETE_BATCH_JOB_INSTANCE));
        LOG.info("Deleted rows number from the BATCH_JOB_INSTANCE table: {}", rowCount);
        totalCount += rowCount;

        contribution.incrementWriteCount(totalCount);

        return RepeatStatus.FINISHED;
    }

    protected String getQuery(String base) {
        return StringUtils.replace(base, "%PREFIX%", tablePrefix);
    }

    public void setTablePrefix(String tablePrefix) {
        this.tablePrefix = tablePrefix;
    }

    public void setHistoricRetentionMonth(Integer historicRetentionMonth) {
        this.historicRetentionMonth = historicRetentionMonth;
    }

    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(jdbcTemplate, "The jdbcTemplate must not be null");
    }

}

Le code source de la classe RemoveSpringBatchHistoryTasklet et sa classe de tests unitaires sont disponibles sur le projet Github spring-batch-toolkit.

Cette tasklet peut être utilisée de 2 manières :

  1. Dans un batch dédié à la purge de l’historique Spring Batch, batch qui pourrait par exemple être exécuté mensuellement ou annuellement selon la durée de rétention choisie.
  2. Dans un step ajouté à un batch existant, par exemple en tant que step final.

Sur mon projet, nous avons opté pour l’option n°2 afin de ne pas démultiplier le nombre de batchs et parce que la mise en production d’un batch ainsi que sa planification s’avèrent toujours laborieux.

Outre le fait de valider les requêtes SQL et leur ordonnancement, le test unitaire permet de se parer face à une éventuelle migration de schéma suite à une montée de version de Spring Batch.

Conclusion

Qui mieux que Spring Batch peut exécuter un traitement de purge pouvant potentiellement manipuler des enregistrements en masse ? Vous connaissez désormais la réponse.

Pour parfaire le code, il aurait été intéressant de déplacer l’exécution des requêtes SQL dans  un DAO héritant de la classe AbstractJdbcBatchMetadataDao. Outre un meilleur design, cela aurait permis de faire un appel au DAO de purge ailleurs que dans un batch. Une telle fonctionnalité pourrait très bien avoir sa place dans la console de Spring Batch Admin.

]]>
https://javaetmoi.com/2012/06/sprint-batch-sauto-nettoie/feed/ 0