Гайд по jOOQ + Gradle

jOOQ сильно отличается от привычных Java ORM (Hibernate, Spring Data JDBC и тп). Причем не только по синтаксису, но еще и нюансами сборки. Открываешь проект и видишь: битые импорты, IDE красная, приложение не собирается, а документация предлагает сначала настроить code generation на несколько экранов XML или Gradle DSL.
В общем штука не самая очевидная. Но когда выполнишь минимальную настройку — на Hibernate возвращаться уже не хочется. Давайте разберемся в основах и сделаем крепенький пример.
Полный код лежит в учебном проекте jooq-gradle-demo. Это небольшое Spring Boot приложение для анонимных опросов. В нем всего две таблицы, но разбираются все основные нюансы: сборка, CRUD, join, aggregation, returning, insert-select и транзакции.
Что такое jOOQ
В целом предполагается, что вы уже имели опыт с каким-нибудь Java ORM: Hibernate (Spring Data JPA), Spring Data Jdbc, ну или хотя бы Jdbi. Так вот каждый из них построен на модели, когда вы описываете ваши таблицы некоторыми Java классами.
Пример entity из hibernate
@Entity
@Table(name = "news_table")
public class NewsEventEntity {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "id", nullable = false, columnDefinition = "uuid")
private UUID id;
@Column(name = "title", nullable = false, length = 256)
private String title;
@Column(name = "message", nullable = false, columnDefinition = "text")
private String message;
}Это очень понятная модель.
ORM примерно представляет, что находится в БД, может сам составлять запросы и даже сверяться с реальной структурой (ddl-auto: validate).
Но такое решение обладает и недостатками:
- легко ошибиться при описании entity
- схема может меняться, нужно не забыть все актуализировать
- часто приходится писать несколько entity на одну таблицу для более сложных запросов
- сложные запросы составлять сложнее, нужно учитывать как ResultSet мапится на Entity
И вот jOOQ как раз решает эти проблемы. Его удобно воспринимать как типизированный SQL-конструктор.
Смотрите, вот обычный запрос, который хочется выполнить:
select id, question, created_at
from surveys
where id = ?;Вот как он будет выглядеть в jOOQ:
create.select(SURVEYS.ID, SURVEYS.QUESTION, SURVEYS.CREATED_AT)
.from(SURVEYS)
.where(SURVEYS.ID.eq(surveyId))
.fetchOptional();Согласитесь, очень похоже на SQL. Причем мы не используем обычные строки для названий таблиц и колонок (это было бы небезопасно и подвержено ихменениям).
SURVEYS.ID здесь не строка, а сгенерированный спец-объект. Поэтому несовместимые выражения ловятся еще при компиляции.
jOOQ не пытается спрятать SQL. Мы все равно думаем терминами select, join, group by, returning, подзапросов и оконных функций.
Просто используем Java DSL для его описания.
Тут возникает резонный вопрос, а откуда взялся этот самый спец-объект SURVEYS? Вот в Hibernate все описывается разработчиком ручками
и это не очень безопасно. На деле идея довольно простая: jOOQ сам читает схему базы, генерирует ее типизированную Java-модель,
а потом позволяет писать SQL через эту модель.
То есть это можно представить, как будто Hibernate сам генерирует свои Entity во время сборки.
Сложность только в том, как это грамотно настроить.
Зачем нужна кодогенерация
Генератор подключается к базе, читает структуру и создает Java-классы для объектов схемы: таблиц, полей, ключей, индексов, records, POJO, DAO, sequences и процедур.
В нашем проекте схема задается Flyway-миграциями:
create table surveys
(
id uuid primary key default uuidv7(),
question varchar(1024) not null,
admin_code_hash varchar(255) not null,
created_at timestamptz not null default now()
);
create table answers
(
id uuid primary key default uuidv7(),
survey_id uuid not null references surveys (id) on delete cascade,
answer varchar(4096) not null,
created_at timestamptz not null default now()
);После генерации появляются готовые классы:
com/karandashov/demo/jooq/generated/
├── DefaultSchema.java
├── Indexes.java
├── Keys.java
├── Tables.java
└── tables/
├── Answers.java
├── Surveys.java
├── daos/
│ ├── AnswersDao.java
│ └── SurveysDao.java
├── pojos/
│ ├── Answers.java
│ └── Surveys.java
└── records/
├── AnswersRecord.java
└── SurveysRecord.javaПолучается можно забыть о несоответствиях:
- удалили колонку — старое обращение к ней перестало компилироваться;
- поменяли тип поля — Java-код увидел новый тип;
- добавили таблицу — получили новое поле в классе;
- переименовали constraint или index — metadata тоже обновилась.
Сгенерированные классы лежат в build/, не коммитятся и не редактируются руками. Они считаются производным артефактом
наравне с .class файлами. Это нужно чтобы у разработчика не было соблазна поправить что-то кривыми ручками.
Окей, как это прикрутить к приложению? Ну очевидно на этап сборки, больше некуда. Когда разработчик нажимает build, должно происходить следующее:
- поднимается временная PostgreSQL в docker-контейнере;
- применяются Flyway или Liquibase миграции;
- отрабатывает jOOQ codegen;
- база данных останавливается.
Такой подход полностью автоматизирован. Не нужно делать отдельный стенд, как советуют в глупых туториалах. Единственная загвоздка — чуть дольше будет выполняться сборка. Можно, конечно, направить codegen в локальную dev-базу, но тогда результат зависит от состояния чужого процесса. Кто-то забыл применить миграцию, кто-то руками добавил колонку, CI вообще не видит эту базу — и generated sources уже отличаются. Ходить генератором в production еще хуже.
Версии jooq, codegen-плагина и generated classes лучше держать одинаковыми. В Spring Boot проекте их удобно
выравнивать через BOM, как это сделано в демо.
Ручной вариант: Gradle + Flyway + Testcontainers
Весь описанный выше процесс можно наладить самому. Первая такая реализация сохранилась в этом коммите. Самописная Gradle-задача сама управляет всем жизненным циклом временной базы.
Полная ручная конфигурация codegen
import org.flywaydb.core.Flyway
import org.jooq.codegen.GenerationTool
import org.jooq.meta.jaxb.Configuration as JooqConfiguration
import org.jooq.meta.jaxb.Database
import org.jooq.meta.jaxb.Generate
import org.jooq.meta.jaxb.Generator
import org.jooq.meta.jaxb.Jdbc
import org.jooq.meta.jaxb.Target
import org.testcontainers.postgresql.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName
// Код внутри build.gradle.kts компилируется отдельно от приложения.
// Поэтому Flyway, jOOQ codegen, PostgreSQL driver и Testcontainers
// добавляем в classpath самого build script.
buildscript {
repositories {
mavenCentral()
}
dependencies {
// В демо версии библиотек выравнивает Spring Boot BOM.
classpath(platform(libs.spring.boot.dependencies))
classpath(libs.flyway.core)
classpath(libs.flyway.database.postgresql)
classpath(libs.jooq.codegen)
classpath(libs.postgresql)
classpath(libs.testcontainers.postgresql)
}
}
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
repositories {
mavenCentral()
}
// Каталог generated sources держим внутри build:
// его можно удалить и в любой момент получить заново.
val generatedJooqDir = layout.buildDirectory.dir("generated-src/jooq/main")
val postgresImage = "postgres:${libs.versions.postgres.get()}"
// Сообщаем Java compiler, где искать сгенерированные классы.
sourceSets {
main {
java {
srcDir(generatedJooqDir)
}
}
}
dependencies {
implementation(libs.spring.boot.starter.flyway)
implementation(libs.spring.boot.starter.jooq)
runtimeOnly(libs.postgresql)
}
val generateJooq by tasks.registering {
group = "jooq"
description = "Generates jOOQ sources from a migrated PostgreSQL Testcontainer"
val migrationsDir =
layout.projectDirectory.dir("src/main/resources/db/migration")
val outputDir = generatedJooqDir
// Если миграции не менялись и output существует,
// Gradle может не запускать задачу и не поднимать контейнер.
inputs.files(fileTree(migrationsDir))
outputs.dir(outputDir)
doLast {
// use гарантирует остановку контейнера после codegen,
// в том числе при исключении.
PostgreSQLContainer(DockerImageName.parse(postgresImage)).use { postgres ->
postgres.start()
// Сначала получаем реальную схему из Flyway-миграций.
Flyway.configure()
.dataSource(
postgres.jdbcUrl,
postgres.username,
postgres.password
)
.locations("filesystem:${migrationsDir.asFile.absolutePath}")
.schemas("public")
.defaultSchema("public")
.validateMigrationNaming(true)
.cleanDisabled(true)
// В демо есть CREATE INDEX CONCURRENTLY.
// Ему нельзя работать внутри transaction block.
.configuration(
mapOf("flyway.postgresql.transactional.lock" to "false")
)
.load()
.migrate()
// Затем jOOQ читает metadata уже мигрированной PostgreSQL.
val configuration = JooqConfiguration()
.withJdbc(
Jdbc()
.withDriver("org.postgresql.Driver")
.withUrl(postgres.jdbcUrl)
.withUser(postgres.username)
.withPassword(postgres.password)
)
.withGenerator(
Generator()
.withDatabase(
Database()
.withName(
"org.jooq.meta.postgres.PostgresDatabase"
)
.withIncludes(".*")
.withExcludes("flyway_schema_history")
.withInputSchema("public")
// Не прибиваем runtime SQL к схеме public.
.withOutputSchemaToDefault(true)
)
.withGenerate(
Generate()
.withDeprecated(false)
.withDaos(true)
.withPojos(true)
.withRecords(true)
.withSpringAnnotations(true)
.withDefaultCatalog(false)
)
.withTarget(
Target()
.withPackageName(
"com.karandashov.demo.jooq.generated"
)
.withDirectory(
outputDir.get().asFile.absolutePath
)
// Удаляем устаревшие generated files.
.withClean(true)
)
)
GenerationTool.generate(configuration)
}
}
}
// Удобный alias с привычным именем.
tasks.register("jooqCodegen") {
group = "jooq"
description = "Alias for generateJooq"
dependsOn(generateJooq)
}
// Чистый checkout должен собираться одной командой.
tasks.named("compileJava") {
dependsOn(generateJooq)
}Здесь нет скрытой магии. Задача последовательно вызывает API трех библиотек:
- Testcontainers создает настоящий PostgreSQL;
- Flyway приводит его к актуальной версии схемы;
GenerationToolзапускает jOOQ codegen.
Плюс ручного варианта — полный контроль и отсутствие зависимости от сторонних плагинов. Если нужно добавить особую миграцию, расширение, несколько схем или дополнительную настройку контейнера, все находится в одном месте.
Минус очевиден: ради одной build-задачи получилось много кода. Его придется сопровождать при обновлении Gradle, Testcontainers, Flyway и jOOQ.
Короткий вариант: dev.monosoul.jooq-docker
В актуальной версии демо ручную задачу заменяет плагин dev.monosoul.jooq-docker.
Он делает то же самое: поднимает контейнер, применяет Flyway-миграции и запускает codegen.
Посмотрите актуальную конфигурацию в build.gradle.kts.
Новая короткая конфигурация
plugins {
java
alias(libs.plugins.jooq.docker)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val generatedJooqDir = layout.buildDirectory.dir("generated-src/jooq/main")
val postgresImage = "postgres:${libs.versions.postgres.get()}"
sourceSets {
main {
java {
srcDir(generatedJooqDir)
}
}
}
dependencies {
implementation(libs.spring.boot.starter.flyway)
implementation(libs.spring.boot.starter.jooq)
runtimeOnly(libs.postgresql)
// Это отдельный classpath задачи generateJooqClasses.
jooqCodegen(platform(libs.spring.boot.dependencies))
jooqCodegen(libs.flyway.core)
jooqCodegen(libs.flyway.database.postgresql)
jooqCodegen(libs.jooq.codegen)
jooqCodegen(libs.postgresql)
}
jooq {
withContainer {
image {
name = postgresImage
}
}
}
tasks.generateJooqClasses {
schemas.set(listOf("public"))
basePackageName.set("com.karandashov.demo.jooq.generated")
migrationLocations.setFromFilesystem("src/main/resources/db/migration")
outputDirectory.set(generatedJooqDir)
includeFlywayTable.set(false)
// Codegen работает с public, а runtime может использовать другую схему.
outputSchemaToDefault.add("public")
flywayProperties.put(
"flyway.postgresql.transactional.lock",
"false"
)
usingJavaConfig {
database.withIncludes(".*")
database.withExcludes("flyway_schema_history")
generate.withDeprecated(false)
generate.withDaos(true)
generate.withPojos(true)
generate.withRecords(true)
generate.withSpringAnnotations(true)
generate.withDefaultCatalog(false)
}
}
tasks.named("compileJava") {
dependsOn(tasks.generateJooqClasses)
}Но плагин сторонний. Его функциональную разработку и сопровождение в основном ведет один человек, а значительная часть свежих обновлений зависимостей автоматизирована. Проект живой, и релизы выходят, однако bus factor все равно низкий.
Именно поэтому выше приведена ручная реализация. Если плагин перестанет подходить, мы не останемся перед черным ящиком.
org.jooq.jooq-codegen-gradle - официальный генератор jOOQ.
dev.monosoul.jooq-docker - сторонняя обертка, которая дополнительно управляет контейнером и миграциями.Что именно сгенерировал jOOQ
Для таблицы surveys нас интересуют четыре вида классов.
Tables.SURVEYS
Это описание таблицы и ее полей:
import static com.karandashov.demo.jooq.generated.Tables.SURVEYS;
SURVEYS.ID // Field<UUID>
SURVEYS.QUESTION // Field<String>
SURVEYS.ADMIN_CODE_HASH // Field<String>
SURVEYS.CREATED_AT // Field<OffsetDateTime>Именно эти объекты используются в DSL. Они содержат SQL-имена, типы, nullability, default values и сведения о ключах.
SurveysRecord
SurveysRecord представляет строку таблицы и наследуется от UpdatableRecordImpl. Его удобно получать из
insert ... returning или selectFrom:
SurveysRecord survey = create.insertInto(SURVEYS)
.set(SURVEYS.QUESTION, question)
.set(SURVEYS.ADMIN_CODE_HASH, adminCodeHash)
.returning()
.fetchOne();Record знает структуру таблицы и может быть attached к jOOQ configuration. Это уже объект jOOQ, а не чистая бизнес-модель.
tables.pojos.Surveys
Это обычный mutable POJO с полями, getters, setters, equals, hashCode и toString. В отличие от JPA entity он не
становится managed-объектом и не обновляет базу после вызова setter.
POJO полезен, когда результат надо передать выше по слоям без зависимости от org.jooq.Record. При этом никто не
заставляет использовать сгенерированные POJO: fetch(...), fetchInto(...) и RecordMapper позволяют отображать
результат в собственные DTO.
SurveysDao
Generated DAO дает готовые операции для одной таблицы (похоже на JpaRepository):
surveysDao.findAll();
surveysDao.fetchOptionalById(surveyId);
surveysDao.fetchByQuestion("Какой релиз показываем?");
surveysDao.insert(survey);
surveysDao.update(survey);
surveysDao.deleteById(surveyId);DAO работает с generated POJO. При включенном springAnnotations класс получает @Repository, а в constructor
инжектится jOOQ Configuration, поэтому Spring может использовать его как обычный bean.
Подключение к Spring Boot
На runtime-стороне достаточно starter’а, JDBC driver и обычной настройки DataSource:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
runtimeOnly("org.postgresql:postgresql")
}spring:
datasource:
url: jdbc:postgresql://localhost:5432/jooq_demo_db
username: test
password: test
hikari:
schema: surveys_schema
jooq:
sql-dialect: POSTGRESSpring Boot автоматически создает DSLContext,
подключает его к DataSource и определяет SQL dialect.
Обратите внимание на разные схемы:
- во временной базе codegen работает со схемой
public; - приложение в runtime работает со схемой
surveys_schema; outputSchemaToDefaultзапрещает генератору жестко вшиватьpublicв SQL.
Без этого jOOQ мог бы генерировать запросы к public.surveys, даже если Hikari и Flyway настроены на другую схему.
С outputSchemaToDefault PostgreSQL разрешает имя через текущий search_path.
Generated DAO: когда достаточно CRUD
В учебном проекте DAO используются там, где запрос укладывается в простую операцию над одной таблицей. Полный пример
лежит в SurveyService:
@Transactional(readOnly = true)
public Surveys getSurvey(UUID surveyId, String adminCode) {
return survey = surveysDao.fetchOptionalById(surveyId)
.orElseThrow(SurveyNotFoundException::missing);
}DAO удобны для:
- поиска по primary key;
- простых фильтров по одной колонке;
insert,update,delete;- небольших внутренних справочников;
- кода, где запрос действительно не сложнее CRUD.
Но это не Spring Data repository. jOOQ не сгенерирует произвольный запрос из имени методаfindAllByQuestionContainingOrderByCreatedAtDesc.
Набор методов определяется схемой и возможностями стандартного org.jooq.DAO.
DAO ориентированы на UpdatableRecord и не поддерживают composite primary key как тип идентификатора.
Для сложных ключей и запросов все равно понадобится DSL.
Не стоит пытаться протащить DAO слишком далеко. Как только появляются join, aggregation, CTE, dynamic conditions или
нестандартный returning, проще написать запрос через DSLContext.
DSLContext: основной API jOOQ
DSLContext хранит jOOQ configuration: dialect,
connection provider, settings, listeners и transaction provider. В Spring Boot его достаточно получить через
constructor injection. Все запросы этого раздела собраны в
SurveyRepository:
Insert с returning
PostgreSQL умеет вернуть созданную строку сразу после insert. jOOQ оставляет эту возможность прямо в DSL:
public SurveysRecord createSurvey(String question, String adminCodeHash) {
return create.insertInto(SURVEYS)
.set(SURVEYS.QUESTION, question)
.set(SURVEYS.ADMIN_CODE_HASH, adminCodeHash)
.returning(
SURVEYS.ID,
SURVEYS.QUESTION,
SURVEYS.ADMIN_CODE_HASH,
SURVEYS.CREATED_AT
)
.fetchOne();
}id и created_at создаются default expressions в PostgreSQL. Отдельный select после insert не нужен.
Значения question и adminCodeHash по умолчанию передаются bind parameters через JDBC PreparedStatement, а не
вставляются в SQL строковой конкатенацией.
Join, aggregation и mapping
Следующий запрос выбирает активный опрос и считает ответы:
public Optional<ActiveSurveyData> findActiveSurvey(
UUID surveyId,
Duration surveyDuration
) {
Field<Long> answerCount =
count(ANSWERS.ID).cast(Long.class).as("answer_count");
return create.select(
SURVEYS.ID,
SURVEYS.QUESTION,
SURVEYS.CREATED_AT,
answerCount
)
.from(SURVEYS)
.leftJoin(ANSWERS)
.on(ANSWERS.SURVEY_ID.eq(SURVEYS.ID))
.where(
SURVEYS.ID.eq(surveyId)
.and(activeSurvey(surveyDuration))
)
.groupBy(
SURVEYS.ID,
SURVEYS.QUESTION,
SURVEYS.CREATED_AT
)
.fetchOptional(record -> new ActiveSurveyData(
record.get(SURVEYS.ID),
record.get(SURVEYS.QUESTION),
record.get(SURVEYS.CREATED_AT),
record.get(answerCount)
));
}Здесь полезны сразу несколько частей API:
Field<Long>можно вынести в переменную и потом использовать и вselect, и при чтении результата;fetchOptional()явно говорит, что строка может отсутствовать;- lambda mapper сразу превращает jOOQ record в прикладной Java
record; - типы полей проверяются компилятором.
Вариантов чтения результата у jOOQ много: fetch(), fetchOne(), fetchSingle(), fetchOptional(), fetchInto(),
fetchMap() и lazy cursor. Их полный список есть в разделе
Fetching:
fetchOne()возвращаетnull, если строки нет, и бросает исключение, если строк больше одной;fetchOptional()выражает тот же контракт черезOptional;fetchSingle()требует ровно одну строку и бросает исключение даже при пустом результате;fetchAny()просто берет одну строку и не проверяет уникальность результата.
Insert-select вместо проверки в Java
При создании ответа надо проверить, что опрос еще активен. Наивный код сделал бы сначала select, а затем insert.
Между двумя запросами состояние уже могло измениться.
В демо проверка и вставка объединены:
public Optional<AnswersRecord> createAnswer(
UUID surveyId,
String answer,
Duration surveyDuration
) {
return create.insertInto(
ANSWERS,
ANSWERS.SURVEY_ID,
ANSWERS.ANSWER
)
.select(
create.select(val(surveyId), val(answer))
.whereExists(
selectOne()
.from(SURVEYS)
.where(
SURVEYS.ID.eq(surveyId)
.and(activeSurvey(surveyDuration))
)
)
)
.returning(
ANSWERS.ID,
ANSWERS.ANSWER,
ANSWERS.CREATED_AT
)
.fetchOptional();
}Если активного опроса нет, внутренняя выборка возвращает ноль строк, insert ничего не создает, а Java получает
Optional.empty().
Переиспользуемые условия и plain SQL
DSL-выражения можно собирать отдельно. Метод проверки срока возвращает обычный Condition:
private Condition activeSurvey(Duration surveyDuration) {
return DSL.condition(
"now() < {0} + ({1} * interval '1 millisecond')",
SURVEYS.CREATED_AT,
val(surveyDuration.toMillis())
);
}Plain SQL templates полезны, когда нужное PostgreSQL-выражение неудобно собирать стандартным DSL. Но начинать с них каждый запрос не стоит: строковый SQL хуже проверяется генератором и компилятором.
Тот же принцип работает для динамических запросов. Condition, Field, Table и другие части jOOQ query model можно
создавать отдельно и комбинировать. Это подробно показано в разделе
Dynamic SQL.
Транзакции
В Spring Boot не нужно вручную доставать JDBC connection ради jOOQ. Starter подключает DSLContext к
transaction-aware DataSource, поэтому обычный @Transactional работает:
@Transactional
public CreateSurveyResponse createSurvey(String question) {
String adminCode = securityService.generate();
String adminCodeHash = securityService.encode(adminCode);
SurveysRecord survey =
surveyRepository.createSurvey(question, adminCodeHash);
return toResponse(survey, adminCode);
}У jOOQ есть и собственный create.transaction(...), но внутри обычного Spring Boot сервиса смешивать две модели без
необходимости не надо. Если проект уже использует Spring TX — @Transactional проще и привычнее.
Практический порядок работы
После первоначальной настройки работа над проектом будет сплошным удовольствием:
- Добавляем Flyway-миграцию.
- Запускаем
./gradlew generateJooqClassesили обычный./gradlew build. - Смотрим, как изменилась generated Java-модель.
- Исправляем compile errors в прикладном коде.
- Для простого CRUD используем generated DAO.
- Для настоящих SQL-запросов берем
DSLContext.
Не редактируйте generated classes! Не запускайте codegen против случайной общей dev-базы! Не прячьте сложный SQL за
пятью слоями абстракций только ради слова Repository!
jOOQ не освобождает от знания SQL. Он делает обратное — позволяет нормально использовать SQL в Java, не превращая запросы в набор непроверяемых строк. Когда схема, codegen и сборка связаны между собой, исчезает самая неприятная часть старта. Дальше остается обычная работа с базой, только с типами и подсказками IDE.