JOOQ et son terrier de lapin. Comment survivre sans hibernation

Dans cet article, je ne me noierai pas pour JOOQ. Je préfÚre Hibernate et toute la puissance JPA de Spring Data derriÚre. Mais l'article ne parlera pas d'eux.



Lorsque nous utilisons Hibernate et Spring Data JPA, nous n'avons pas besoin de penser aux processus internes - connaßtre les annotations et écrire les noms de méthode corrects dans le référentiel - ces deux monstres feront le reste pour vous. Dans le cas de JOOQ, malheureusement pour beaucoup, vous devez tendre un peu et écrire plus que findAllByUserId (Long userId) .

Qu'est-ce qu'un JOOQ?


Écrivons une simple requĂȘte SQL:

select * from countries c where c.population > 10000000;

Nous pouvons exécuter cette demande dans la console. d'accord

Nous ne nous sentons pas comme dans la console. Nous voulons que notre application envoie cette demande. Que ce n'était pas un script à usage unique et qu'il a été validé au moins pour la syntaxe. Nous le faisons via Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

La mĂȘme demande est exĂ©cutĂ©e comme ci-dessus, mais au printemps.

Quels sont les avantages Ă©vidents de cette approche? Elle est rĂ©alisĂ©e par un framework puissant; elle valide Ă©galement une demande d'erreurs. Mais la gestion des requĂȘtes s'occupe du framework. Et nous voulons gĂ©rer complĂštement la demande, mais en mĂȘme temps, pour que la demande soit complĂštement validĂ©e.
UtilisationRequete:

@Query("select c from Country c where c.population > :amount")
List<Country> findAllPopulationGreaterThan(@Param("amount") Long amount);

Un tel compromis entre SQL et DSL est Ă©galement bon. Mais si nous ne voulons pas jouer avec SQL, nous serons heureux de voir quelque chose comme:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();

Plusieurs bibliothĂšques conviennent Ă  cela:

QueryDSL
JOOQ
Speedment


À propos de QueryDSL J'ai Ă©crit il y a quelques annĂ©es. Je n'ai pas creusĂ© Speedment, mais il semble que ce soit plus qu'un simple gĂ©nĂ©rateur DSL, et je devrai apprendre les mĂ©thodes par lesquelles ils ont chiffrĂ© les commandes de requĂȘte SQL. En gĂ©nĂ©ral, nous parlerons aujourd'hui de l'OJOQ.

RequĂȘtes JOOQ


Oui, nous avons dĂ©jĂ  vu l'une de ces requĂȘtes ci-dessus:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();

Qu'y a-t-il d'autre?

Par exemple, une simple requĂȘte get:

return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny();

Ou insérez la demande:

return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne();

Comme vous pouvez le voir, tout est clair, rien de plus. Mais ce n'est qu'Ă  premiĂšre vue. DĂ©couvrir Ă  quelle profondeur ce terrier de lapin peut prendre plusieurs semaines.

Mais commençons par le début.

Oui ce sera maven


Je prĂ©fĂšre Gradle. Mais pour une raison quelconque, les dĂ©veloppeurs JOOQ accordent moins d'attention Ă  Gradle, proposant d'opter pour des plugins tiers. D'accord, le projet de formation sera sur Maven. Mais si vous, cher lecteur, fouillez comme il se doit dans les profondeurs du github et ĂȘtes prĂȘt Ă  rĂ©vĂ©ler un projet entiĂšrement personnalisĂ© sur Gradle - Ă©crivez-moi et vous deviendrez co-auteur de cet article. Il en va de mĂȘme pour H2 (le projet de formation sera sur PostgreSQL).

Pourquoi je n'ai pas réussi avec Gradle et H2
, Gradle. jooq-generator . H2, « » H2 , , , .

Dépendances Maven nécessaires:

<dependencies>

        <!-- Spring Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jooq</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Database -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Helpers -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons.lang3.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- JOOQ Generator -->
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-meta</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-codegen</artifactId>
            <version>${jooq.version}</version>
        </dependency>

    </dependencies>

Spring a dĂ©jĂ  un dĂ©marreur JOOQ qui auto-configurera le DataSource selon les paramĂštres spĂ©cifiĂ©s dans application.yml. Mais pour le travail complet de l'OJOQ, cela ne suffit pas. Les entitĂ©s doivent ĂȘtre gĂ©nĂ©rĂ©es. Oui, oui, nous n'Ă©crivons pas d'entitĂ©s, mais nous les gĂ©nĂ©rons. L'approche dite de la table d'abord.

La structure d'application habituelle, axée sur l'utilisation de la base de données, ressemble à ceci:



jusqu'à la couche de service, les données transitent par l'application sous la forme d'un modÚle plat (DTO). De plus, lorsque le service doit travailler avec la base de données, il convertit le DTO en une entité, l'envoie au référentiel et celui-ci le sauvegarde déjà dans la base de données. Les développeurs de JOOQ voient tout différemment:



comme on peut le voir, à JOOQ, ils ont sérieusement révisé le modÚle standard et ont décidé de suivre leur propre chemin. Les différences sont importantes:

  1. La conversion de DTO en une entité se produit déjà dans le référentiel.
  2. Nous n'écrivons pas une entité en tant que telle. Il est écrit pour nous par le générateur JOOQ.

DĂ©jĂ , cela suffit pour conclure que l'OJOQ n'est pas si simple. Une entitĂ© gĂ©nĂ©rĂ©e hautement personnalisable, une cartographie incomprĂ©hensible dans DTO et d'autres difficultĂ©s en effrayeront beaucoup. Mais dans les cas oĂč il n'y a nulle part oĂč aller, une Ă©tude ciblĂ©e de ces aspects de l'OJOQ peut sĂ©rieusement se prolonger et prendre des jours, voire des semaines. Je suis sĂ»r que mon message vous fera gagner beaucoup de temps.

Nous couvrirons les aspects suivants de la collaboration avec l'OJOQ:

  • GĂ©nĂ©ration d'entitĂ©.
  • Écriture de requĂȘtes CRUD.
  • Optimisation des requĂȘtes CRUD.
  • Et une autre optimisation de demande CRUD.
  • Mappage d'entitĂ©s Ă  l'aide de la bibliothĂšque JOOQ standard.
  • et discuter pourquoi tout cela est nĂ©cessaire.

Allons-y.

Qu'Ă©crivons-nous?


Notre projet comportera deux entités:

Pays:

@Data
public class Country {

    private Long id;
    private String name;
    private GovernmentForm governmentForm;
    private Integer population;

    private List<City> cities;
}

Ville:

@Data
public class City {

    private Long id;
    private Long countryId;
    private String name;
}

Relation un à plusieurs. Le pays contient de nombreuses villes liées. La ville contient countryId.
La migration des voies de migration ressemble Ă  ceci:

create table countries
(
    id              bigserial primary key,
    name            varchar(255),
    government_form varchar(255),
    population      int
);

create table cities
(
    id         bigserial primary key,
    country_id bigint,
    name       varchar(255)
);

Commençons par la génération d'entités.


Comme je l'ai dit, les entités sont générées séparément. Il y a deux façons de faire ça.

  • GrĂące au plugin Maven.
  • En code Java.

Génération d'entités dans Maven


Lorsque le projet se construit, Maven dĂ©marre le gĂ©nĂ©rateur et gĂ©nĂšre des entitĂ©s. Et vous pouvez appeler le gĂ©nĂ©rateur Ă  tout moment et gĂ©nĂ©rer des entitĂ©s. Cela est nĂ©cessaire dans les moments oĂč, disons, la structure de la base a changĂ©. Le plugin dans Maven ressemble Ă  ceci:

            <!-- JOOQ Generator Plugin -->
            <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <jdbc>  <!--    -->
                        <driver>${db.driver}</driver>
                        <url>${db.url}</url>
                        <user>${db.username}</user>
                        <password>${db.password}</password>
                    </jdbc>
                    <generator>
                        <database>
                            <includes>.*</includes>  <!--     -->
                            <excludes>  <!--     -->
                                flyway_schema_history
                            </excludes>
                            <inputSchema>public</inputSchema>  <!--  -->
                        </database>
                        <generate>
                            <records>true</records>
                        </generate>
                        <target>
                            <!--      -->
                            <packageName>ru.xpendence.jooqexample.domain</packageName>
                            <!--  .    target. -->
                            <directory>target/generated-sources/jooq</directory>
                        </target>
                    </generator>
                </configuration>
            </plugin>

Si vous avez tout fait correctement, mettez Ă  jour Maven et parmi les plugins vous verrez jooq-codegen.



Exécuter. Il générera des entités pour vous.



Ce sont les mĂȘmes entitĂ©s que vous utiliserez pour accĂ©der Ă  la base de donnĂ©es. PremiĂšre nuisance: ils sont immuables. Autrement dit, ils sont modifiables, mais le changement de type se produit d'une maniĂšre si artificielle que cette description de processus sera tirĂ©e vers un article distinct. Par consĂ©quent, essayez de rĂ©flĂ©chir aux types de donnĂ©es au stade de la gĂ©nĂ©ration de la table.

Les entités semblent particuliÚres. Voici, par exemple, la signature de la classe CountriesRecord:

public class CountriesRecord extends UpdatableRecordImpl<CountriesRecord> implements Record4<Long, String, String, Integer> {
    //...

Comme nous pouvons le voir, CountriesRecord implĂ©mente Record4, qui est tapĂ© par 4 types. Le tableau des pays a 4 colonnes, donc Record4. Il y a 3 colonnes dans les villes, donc Record3. Pourquoi cela a Ă©tĂ© inventĂ©, je ne sais pas. Il y a 22 enregistrements de ce type dans la bibliothĂšque OJOQ, Record1 ... Record22. D'oĂč nous pouvons conclure que les capacitĂ©s de JOOQ sont limitĂ©es au traitement des tables avec un maximum de 22 colonnes, mais ce n'est pas le cas. J'ai des tables avec des dizaines de colonnes et JOOQ implĂ©mente immĂ©diatement Record. Eh bien, cela ... La

génération d'entités JOOQ en code Java ressemble à ceci:

@Component
public class AfterStartupApplicationListener implements ApplicationListener<ContextRefreshedEvent> {

    @Value("${spring.datasource.driver-class-name}")
    private String driver;

    @Value("${spring.datasource.url}")
    private String url;

    @Value("${spring.datasource.username}")
    private String username;

    @Value("${spring.datasource.password}")
    private String password;

    @Value("${jooq.generator.database.name}")
    private String databaseName;

    @Value("${jooq.generator.database.with-includes}")
    private String databaseWithIncludes;

    @Value("${jooq.generator.database.with-input-schema}")
    private String databaseWithInputSchema;

    @Value("${jooq.generator.target.package-name}")
    private String targetPackageName;

    @Value("${jooq.generator.target.directory}")
    private String targetDirectory;

    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        new GenerationTool().run(configureGenerator());
    }

    private Configuration configureGenerator() {
        return new Configuration()
                .withJdbc(new Jdbc()
                        .withDriver(driver)
                        .withUrl(url)
                        .withUser(username)
                        .withPassword(password))
                .withGenerator(new Generator()
                        .withDatabase(new Database()
                                .withName(databaseName)
                                .withIncludes(databaseWithIncludes)
                                .withExcludes("")
                                .withInputSchema(databaseWithInputSchema))
                        .withTarget(new Target()
                                .withPackageName(targetPackageName)
                                .withDirectory(targetDirectory)));
    }
}

Dans mon cas, le générateur démarre aprÚs un démarrage complet de l'application, lorsque toutes les nouvelles migrations sont cumulées et que la base de données est à jour.

Donc, nous avons des modĂšles, nous avons des entitĂ©s, nous avons une base. Il est temps de crĂ©er un rĂ©fĂ©rentiel et d'Ă©crire des requĂȘtes.

Créer un référentiel


Toutes les demandes passent par DslContext. Répétez le DslContext.

@Repository
@RequiredArgsConstructor
public class CountryRepository implements CrudRepository<Country> {

    private final DSLContext dsl;

} 

Le rĂ©fĂ©rentiel est prĂȘt.

Écriture de requĂȘtes CRUD


Insérer


Si nous utilisions SQL, la demande d'ajout d'un autre pays serait:

insert into countries(name, government_form, population)
values (' -', 'UNITARY', 100500) returning *;

Dans JOOQ, une requĂȘte est aussi proche que possible de la syntaxe SQL:

    public Country insertValues(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)  //insert into countries
                .values(country.getId(), country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))  //values (? ? ? ?)
                .returning()  //returning
                .fetchOne()  //*
                .into(Country.class);
    }

Comme on le voit, rien de compliqué. Cependant, une telle demande ne nous convient pas, car dans notre cas, l'ID est généré dans la base de données et nous devons en tenir compte. Si nous écrivons quelque chose comme:

.values(null, country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))

nous aurons

org.postgresql.util.PSQLException: :     "id"   NOT NULL

Cependant, si nous générions l'ID du cÎté de l'application, une telle demande serait un tour. Nous devrons réécrire la demande, en donnant à chaque champ une valeur spécifique. Afin que vous puissiez:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne()
                .into(Country.class);
    }

Dans ce cas, nous définissons manuellement l'objet dans le champ. Cette option fonctionne assez bien, mais il y a mieux. Il serait idéal d'envoyer l'objet entier pour sauvegarde, comme cela se fait dans Spring Data JPA:

repository.save(country);

Cela pourrait ĂȘtre le cas. Pour ce faire, nous devons mapper notre modĂšle dans l'entitĂ© Record Ă©tendue et l'envoyer pour enregistrement:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))  //   
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

Cette mĂ©thode est la plus simple et la plus comprĂ©hensible dans les cas oĂč nous n'avons pas besoin de configurer le mappage. Mais le rĂ©glage de la cartographie sera plus bas.

Comme nous l'avons remarquĂ©, cette requĂȘte renvoie l'entitĂ© entiĂšre. Vous pouvez dĂ©terminer exactement ce que vous devez retourner dans la mĂ©thode fetch (). Cela ressemble Ă  ceci:

    public Long insertAndReturnId(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .returning(Countries.COUNTRIES.ID) //  Record    - id
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .get(Countries.COUNTRIES.ID); // id
    }

Un peu encombrant, mais, encore une fois, avec quoi comparer.

Nous écrirons le reste des méthodes CRUD.

Mise Ă  jour


RequĂȘte SQL:

update countries
set name            = '',
    government_form = 'CONFEDERATE',
    population      = 100500
where id = 1
returning *;

Demande JOOQ:

    public Country update(Country country) {
        return dsl.update(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .where(Countries.COUNTRIES.ID.eq(country.getId()))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error updating entity: " + country.getId()))
                .into(Country.class);
    }

SĂ©lectionner


Une requĂȘte en SQL serait:

select *
from countries c
where id = ?;

Dans JOOQ, la requĂȘte se prĂ©sentera en consĂ©quence:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES) //select * from countries
                .where(Countries.COUNTRIES.ID.eq(id))  //where id = ?
                .fetchAny()  // ,    
                .into(Country.class);
    }

Supprimer


Il y a des demandes dans lesquelles nous n'avons rien Ă  retourner. Ces requĂȘtes renvoient le nombre de lignes affectĂ©es. En suppression, nous n'avons rien Ă  retourner, mais les informations que nous recevons nous seront toujours utiles.

    public Boolean delete(Long id) {
        return dsl.deleteFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .execute() == 1;
    }

Nous supprimons une ligne. Ainsi, la requĂȘte SQL retournera quelque chose comme:

1 row affected in 5 ms

Ayant reçu une telle réponse, nous savons avec certitude que la ligne a été supprimée. Ce sera suffisant pour déclarer l'opération réussie.

Ce n'était pas un conte de fées. C'était juste un lubrifiant. Utilisation d'un mappeur standard pour le mappage fin d'une entité dans Record et vice versa


"D'accord," dites-vous, lecteur, "oĂč est un Ă  plusieurs, emoe?" Quelque chose que je n'ai pas vu le moment oĂč le pays grandit avec une multitude de villes. »

Et vous aurez raison. Ce n'est pas Hibernate; ici, vous devez le faire manuellement. Tout ce que vous devez faire est d'obtenir une liste des villes pour lesquelles id = country.getId (). J'ai déjà montré la méthode into (), qui utilise un mappeur normal pour mapper Record dans une entité. Mais JOOQ a une méthode map () supplémentaire qui nous permet de faire tout ce que nous voulons avec Record. Examinons ses fonctionnalités et ajoutons l'ajout de villes:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> {
                    Country country = r.into(Country.class);
                    country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
                    return country;
                });
    }

Comme nous le voyons, maintenant nous mappons d'abord l'enregistrement dans le pays, puis nous faisons une autre demande dans les villes, nous obtenons toutes les villes pour ce pays et les définissons en substance.

Mais il peut y avoir des dizaines de tels ensembles, ce qui signifie qu'il peut y avoir des dizaines de demandes. Et dĂ©crire ces requĂȘtes directement dans la mĂ©thode, c'est comme ça. La bonne solution consiste Ă  Ă©crire un mappeur distinct et Ă  y placer toutes ces demandes.

@RequiredArgsConstructor
@Component
public class CountryRecordMapper implements RecordMapper<CountriesRecord, Country> {

    private final CityRepository cityRepository;

    @Override
    public Country map(CountriesRecord record) {
        Country country = record.into(Country.class);
        country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
        return country;
    }
}

La requĂȘte ressemblera maintenant Ă  ceci:

    public Country findWithCustomMapper(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> countryRecordMapper.map((CountriesRecord) r));
    }

Il est plus concis et ne contient pas de logique supplémentaire.

Ok, nous avons appris à mapper Record dans une entité, mais qu'en est-il du réglage fin du mappage d'une entité dans Record?


Jusqu'à présent, nous avons cette conception:

.set(dsl.newRecord(Countries.COUNTRIES, country))

DĂ©jĂ  bon, mais que se passe-t-il si nous devons cartographier spĂ©cifiquement un champ? Par exemple, nous avons LocalDateTime et un gĂ©nĂ©rateur pour PostgreSQL comme Timestamp a gĂ©nĂ©rĂ© un OffsetDateTime. Dans ce cas, le champ ne sera tout simplement pas mappĂ© et non enregistrĂ© dans la base de donnĂ©es. Pour de tels cas, nous aurons besoin d'un autre mappeur qui fera de mĂȘme, mais dans la direction opposĂ©e.

Oui, pour chaque mappeur, nous avons un mappeur


Il s'appelle ainsi. Écrivons son hĂ©ritier.

@Component
@RequiredArgsConstructor
public class CountryRecordUnmapper implements RecordUnmapper<Country, CountriesRecord> {

    private final DSLContext dsl;

    @Override
    public CountriesRecord unmap(Country country) throws MappingException {
        CountriesRecord record = dsl.newRecord(Countries.COUNTRIES, country);
        record.setPopulation(-1);
        return record;
    }
}

Insérer avec son application ressemblera à ceci:

    public Country insertWithUnmapper(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(countryRecordUnmapper.unmap(country))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

Comme nous le voyons, aussi tout Ă  fait.

Conclusions et conclusions


Personnellement, j'aime plus Hibernate. Probablement, pour 90 +% des applications son utilisation sera plus justifiée. Mais si vous, le lecteur, souhaitez contrÎler chaque demande, JOOQ ou une autre bibliothÚque similaire vous convient mieux.

Comme toujours, je poste le projet de formation. Il est allongé ici .

All Articles