JOOQ y su madriguera de conejo. Cómo sobrevivir sin hibernar

En este artículo no me ahogaré por JOOQ. Prefiero Hibernate y todo el poder de Spring Data JPA detrás de él. Pero el artículo no será sobre ellos.



Cuando utilizamos Hibernate y Spring Data JPA, no necesitamos pensar en procesos internos; conocer las anotaciones y escribir los nombres de método correctos en el repositorio; estos dos monstruos harán el resto por usted. En el caso de JOOQ, desafortunadamente para muchos, tomará un poco de esfuerzo y escribirá más que findAllByUserId (Long userId) .

¿Qué es un JOOQ?


Escribamos una consulta SQL simple:

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

Podemos ejecutar esta solicitud en la consola. Bueno

No nos sentimos como en la consola. Queremos que nuestra aplicación envíe esta solicitud. Que no era un script de una sola vez y que estaba validado al menos para la sintaxis. Hacemos esto a través de Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

La misma solicitud se ejecuta como anteriormente, pero por Spring.

¿Cuáles son las claras ventajas de este enfoque? Se realiza mediante un potente marco; también valida una solicitud de errores. Pero la gestión de consultas se encarga del marco. Y queremos gestionar completamente la solicitud, pero al mismo tiempo, para que la solicitud esté completamente validada.
UtilizarConsulta:

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

Tal compensación entre SQL y DSL también es buena. Pero si no queremos jugar con SQL, estaremos encantados de ver algo como:

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

Varias bibliotecas son adecuadas para esto:

QueryDSL
JOOQ
Speedment


Acerca de QueryDSL Escribí hace un par de años. No excavé Speedment, pero parece ser algo más que un simple generador de DSL, además tendré que aprender los métodos por los que cifraron los comandos de consulta SQL. En general, hoy hablaremos de JOOQ.

Consultas JOOQ


Sí, ya hemos visto una de estas consultas arriba:

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

Que mas hay

Por ejemplo, una simple solicitud de obtención:

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

O inserte la solicitud:

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();

Como puede ver, todo está claro, nada más. Pero esto es solo a primera vista. Averiguar qué tan profundo puede llevar este agujero de conejo varias semanas.

Pero comencemos desde el principio.

Sí, será maven


Prefiero Gradle Pero por alguna razón, los desarrolladores de JOOQ prestan menos atención a Gradle, que ofrecen optar por complementos de terceros. De acuerdo, el proyecto de capacitación estará en Maven. Pero si usted, querido lector, hurgó en las profundidades del github y está listo para revelar un proyecto totalmente personalizado en Gradle, escríbame y se convertirá en coautor de este artículo. Lo mismo se aplica a H2 (el proyecto de capacitación estará en PostgreSQL).

Por qué no tuve éxito con Gradle y H2
, Gradle. jooq-generator . H2, « » H2 , , , .

Dependencias necesarias de Maven:

<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 ya tiene un iniciador JOOQ que autoconfigurará el DataSource de acuerdo con la configuración especificada desde application.yml. Pero para el trabajo completo de JOOQ, esto no es suficiente. Se deben generar entidades. Sí, sí, no escribimos entidades, sino que las generamos. El llamado enfoque de primera mesa.

La estructura habitual de la aplicación con un enfoque en el trabajo con la base de datos se ve así:



hasta la capa de servicio, los datos viajan a través de la aplicación en forma de un modelo plano (DTO). Además, cuando el servicio necesita trabajar con la base de datos, convierte el DTO en una entidad, lo envía al repositorio y ese ya lo guarda en la base de datos. Los desarrolladores de JOOQ ven todo de manera diferente:



como podemos ver, JOOQ revisó seriamente el patrón estándar y decidió seguir su propio camino. Las diferencias son significativas:

  1. La conversión de DTO en una entidad ya ocurre en el repositorio.
  2. No escribimos una entidad como tal. Está escrito para nosotros por el generador JOOQ.

Esto ya es suficiente para concluir que JOOQ no es tan simple. Una entidad generada altamente personalizable, mapeo incomprensible en DTO y otras dificultades asustarán a muchos. Pero en los casos en que no hay a dónde ir, un estudio enfocado de estos aspectos de JOOQ puede llevarse seriamente y llevar días, o incluso semanas. Estoy seguro de que mi publicación le ahorrará mucho tiempo.

Cubriremos los siguientes aspectos del trabajo con JOOQ:

  • Entidad Generación.
  • Redacción de consultas CRUD.
  • Optimización de solicitudes CRUD.
  • Y otra optimización de solicitud CRUD.
  • Asignación de entidades utilizando la biblioteca JOOQ estándar.
  • y discuta por qué todo esto es necesario.

Vamonos.

Que escribimos


Nuestro proyecto tendrá dos entidades:

País:

@Data
public class Country {

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

    private List<City> cities;
}

Ciudad:

@Data
public class City {

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

Relación uno a muchos. El país contiene muchas ciudades relacionadas. La ciudad contiene countryId.
La migración de ruta de vuelo se ve así:

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)
);

Comencemos con la generación de entidades.


Las entidades, como dije, se generan por separado. Hay dos maneras de hacer esto.

  • A través del complemento Maven.
  • En código Java.

Entidades generadoras en Maven


Cuando el proyecto se construye, Maven inicia el generador y genera entidades. Y puede llamar al generador en cualquier momento conveniente y generar entidades. Esto es necesario en aquellos momentos en que, por ejemplo, la estructura de la base ha cambiado. El complemento en Maven se ve así:

            <!-- 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 hizo todo correctamente, actualice Maven y entre los complementos verá jooq-codegen.



Ejecutarlo. Generará entidades para ti.



Estas son las mismas entidades que utilizará para acceder a la base de datos. La primera molestia: son inmutables. Es decir, son mutables, pero el cambio de tipo ocurre de una manera tan artificial que esta descripción del proceso pasará a un artículo separado. Por lo tanto, trate de pensar sobre los tipos de datos en la etapa de generación de tablas.

Las entidades se ven peculiares. Aquí, por ejemplo, está la firma de la clase CountriesRecord:

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

Como podemos ver, CountriesRecord implementa Record4, que está escrito en 4 tipos. La tabla de países tiene 4 columnas, por lo tanto, Record4. Hay 3 columnas en las ciudades, entonces Record3. Por qué se inventó esto, no lo sé. Hay 22 de estos registros en la biblioteca JOOQ, Record1 ... Record22. De lo cual podemos concluir que las capacidades de JOOQ se limitan al procesamiento de tablas con un máximo de 22 columnas, pero esto no es así. Tengo tablas con docenas de columnas, y JOOQ implementa inmediatamente Record. Bueno, eso ...

Generar entidades JOOQ en código Java se ve así:

@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)));
    }
}

En mi caso, el generador se inicia después de un inicio completo de la aplicación, cuando se completan todas las migraciones nuevas y la base de datos está actualizada.

Entonces, tenemos modelos, tenemos entidades, tenemos una base. Es hora de crear un repositorio y escribir consultas.

Crear repositorio


Todas las solicitudes pasan por DslContext. Repita el DslContext.

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

    private final DSLContext dsl;

} 

El repositorio está listo.

Escribir consultas CRUD


Insertar


Si utilizamos SQL, la solicitud para agregar otro país sería:

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

En JOOQ, una consulta está lo más cerca posible de la sintaxis 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);
    }

Como vemos, nada complicado. Sin embargo, tal solicitud no nos conviene, porque en nuestro caso el ID se genera en la base de datos y debemos tenerlo en cuenta. Si escribimos algo como:

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

Nosotros recibiremos

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

Sin embargo, si generamos la identificación en el lado de la aplicación, tal solicitud sería un paseo. Tendremos que reescribir la solicitud, dando a cada campo un valor específico. Así que puedes:

    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);
    }

En este caso, establecemos manualmente el objeto en el campo. Esta opción funciona bastante, pero hay una mejor. Sería ideal enviar todo el objeto para guardarlo, como se hace en Spring Data JPA:

repository.save(country);

Podría ser así. Para hacer esto, necesitamos mapear nuestro modelo en la entidad Record extendida y enviarlo para guardarlo:

    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);
    }

Este método es el más simple y más comprensible para aquellos casos en los que no necesitamos configurar el mapeo. Pero la configuración del mapeo será menor.

Como notamos, esta consulta devuelve toda la entidad. Puede determinar qué es exactamente lo que necesita devolver en el método fetch (). Se parece a esto:

    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 poco engorroso, pero, de nuevo, con qué comparar.

Escribiremos el resto de los métodos CRUD.

Actualizar


Consulta SQL:

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

Solicitud 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);
    }

Seleccione


Una consulta en SQL sería:

select *
from countries c
where id = ?;

En JOOQ, la consulta se verá en consecuencia:

    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);
    }

Eliminar


Hay solicitudes en las que no tenemos nada que devolver. Dichas consultas devuelven el número de filas afectadas. En eliminar, no tenemos nada que devolver, pero la información que recibimos aún nos será útil.

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

Eliminamos una línea. Entonces la consulta SQL devolverá algo como:

1 row affected in 5 ms

Habiendo recibido tal respuesta, sabemos con certeza que la fila ha sido eliminada. Esto será suficiente para declarar la operación exitosa.

No era un cuento de hadas. Era solo un lubricante. Usando un maper regular para mapeo fino de una entidad en Record y viceversa


"Está bien", dices, lector, "¿dónde está uno a muchos, emoe?" Algo que no vi en el momento en que Country crece con una multitud de City ".

Y tendrás razón. Esto no es Hibernate; aquí tienes que hacerlo manualmente. Todo lo que necesita hacer es obtener una lista de City para la cual id = country.getId (). Ya he mostrado el método into (), que usa un mapeador regular para mapear Record en una entidad. Pero JOOQ tiene un método map () adicional que nos permite hacer lo que queramos con Record. Veamos su funcionalidad y agreguemos la adición de ciudades:

    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;
                });
    }

Como vemos, ahora primero mapeamos el registro en el país, y luego hacemos otra solicitud en las ciudades, obtenemos todas las ciudades para este país y las establecemos en esencia.

Pero puede haber docenas de estos conjuntos, lo que significa que puede haber docenas de solicitudes. Y describir estas solicitudes directamente en el método es así. La solución correcta es escribir un mapeador separado y colocar todas estas solicitudes allí.

@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 consulta ahora se verá así:

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

Es más conciso y no contiene lógica adicional.

Ok, aprendimos cómo mapear Record en una entidad, pero ¿qué hay de ajustar la asignación de una entidad en Record?


Hasta ahora tenemos este diseño:

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

Ya está bien, pero ¿qué pasa si necesitamos mapear específicamente un campo? Por ejemplo, tenemos LocalDateTime allí, y un generador para PostgreSQL como Timestamp generó un OffsetDateTime. En este caso, el campo simplemente no se asignará y no se registrará en la base de datos. Para tales casos, necesitaremos otro mapeador que haga lo mismo, pero en la dirección opuesta.

Sí, para cada mapeador tenemos un mapeador


Él se llama así. Escribamos a su heredero.

@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;
    }
}

Insertar con su aplicación se verá así:

    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);
    }

Como vemos, también bastante.

Conclusiones y conclusiones


Personalmente, me gusta más Hibernate. Probablemente, para más del 90% de las aplicaciones, su uso estará más justificado. Pero si usted, el lector, desea controlar cada solicitud, JOOQ u otra biblioteca similar es más adecuada para usted.

Como siempre, publico el proyecto de capacitación. El esta acostado aquí .

All Articles