Java. jOOQ

Зміст

Довгочит буде про jOOQ — бібліотеку, яка зручно поєднує світ Java і SQL. Якщо ви працюєте з базами даних у Java, то, скоріш за все, зустрічались з такими дилемами:

  • JDBC — ну це такий собі "танець із бубном". Так, працює швидко, але запити у вигляді рядків? Серйозно?)? Ризик помилитися чи прогледіти щось критичне — просто величезний. Тому це можна навіть не розглядати.

  • JPA/Hibernate — класно, поки не треба виконати щось складніше за SELECT * FROM table. І ні, магія ORM не завжди допоможе. Часом вона більше заважає.

Ось тут з’являється jOOQ (Java Object Oriented Querying).

Що таке jOOQ?

jOOQ (Java Object Oriented Querying) — це бібліотека, яка дозволяє працювати з SQL-запитами у Java-стилі: без магії, але з типобезпекою і зручною DSL (Domain Specific Language). Якщо коротко, це любов із першого погляду для тих, хто хоче балансувати між гнучкістю "чистого SQL" і комфортом ORM.

jOOQ генерує Java-класи для ваших таблиць, надаючи можливiсть будувати запити так, як ви це робили б в чистому SQL, але з повною пiдтримкою автозавершення та перевiрки на етапi компiляцiї.

JPA і jOOQ

Основна відмінність між

  • JPA. Працює з перехідними станами об'єктів (entity state transitions), тобто зосереджується на станах сутностей та операціях із ними (insert, update, delete, cascade тощо).

  • jOOQ. Працює з перетвореннями наборів даних (data set transformations), дозволяючи повністю контролювати SQL-запити та отримувати гнучкість у їх написанні.

Ці два підходи підходять до різних задач:

  • JPA краще підходить для домен-орієнтованого дизайну (DDD) або коли потрібна автоматизація змін стану сутностей.

  • jOOQ ідеально підходить для складних запитів, роботи з процедурними функціями БД та масштабних трансформацій даних.

Принцип роботи jOOQ: Database First

jOOQ базується на принципі Database First, тобто:

  • Ваша база даних вже існує (або створена окремо).

  • jOOQ не вимагає дотримання будь-яких специфічних правил щодо дизайну бази даних.

  • На основі існуючої схеми бази jOOQ використовує генератор, щоб створити:

    • Java-класи для таблиць, колонок, ключів та інших об'єктів.

    • DSL-код для роботи з SQL.

Цей підхід забезпечує незалежність та гнучкість, особливо в проектах, де база даних уже є важливим елементом системи.

Superpower jOOQ. Що робить його унікальним?

  • Гнучкість запитів. jOOQ дозволяє виконувати найскладніші SQL-запити, включаючи вкладені вибірки, агрегації, віконні функції, тощо.

  • Робота з процедурами та функціями. Підтримка викликів збережених процедур і функцій бази даних.

  • Масові операції. Bulk insert, update, delete реалізуються ефективно.

  • Читання вкладених об'єктів. Легко зчитувати складні структури даних з глибокою ієрархією.

Концепцiї jOOQ

DSLContext

DSLContext — це основний API для роботи з jOOQ. Через нього ви створюєте та виконуєте SQL-запити. Ви можете уявити DSLContext як обгортку навколо вашого “з'єднання” до бази, яка забезпечує зручний i типобезпечний синтаксис для побудови запитiв.

// Створення DSLContext через конфiгурацiю
DSLContext ctx = DSL.using(configuration);

// Або напряму через DataSource
DSLContext ctx = DSL.using(dataSource, SQLDialect.POSTGRES);

Типобезпечнi таблицi та колонки

Пiсля генерацiї моделей (процес описаний нижче), jOOQ створює Java-класи, що вiдповiдають кожнiй таблицi у вашiй базi. Наприклад, якщо у вас є таблиця users з колонками id, name, i age, jOOQ створить клас Users i статичнi поля для кожної колонки:

// Автоматично згенерованi класи
public class Users extends TableImpl<Record> {
    public static final TableField<Record, Integer> ID = createField("id", SQLDataType.INTEGER);
    public static final TableField<Record, String> NAME = createField("name", SQLDataType.VARCHAR);
    public static final TableField<Record, Integer> AGE = createField("age", SQLDataType.INTEGER);
}

Тепер, щоб зробити запит, можна використовувати цi класи:

Result<Record> result = ctx.select(USERS.ID, USERS.NAME)
                           .from(USERS)
                           .where(USERS.AGE.gt(18))
                           .fetch();

result.forEach(record -> {
    System.out.println("User: " + record.get(USERS.NAME));
});

Генерацiя моделей

Однiєю з ключових фiшок jOOQ є автоматична генерацiя Java-класiв для ваших таблиць. Це досягається за допомогою jooq-codegen, який пiдключається до вашої бази, читає схему i створює вiдповiднi класи.

Як це налаштувати:

  1. Додати заежнiсть для jooq-codegen у ваш pom.xml:

<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen</artifactId>
    <version>3.x.x</version>
</dependency>
  1. Створити XML-конфiг для генерацiї моделей:

<configuration>
    <jdbc>
        <driver>org.postgresql.Driver</driver>
        <url>jdbc:postgresql://localhost:5432/mydb</url>
        <user>myuser</user>
        <password>mypassword</password>
    </jdbc>
    <generator>
        <database>
            <name>org.jooq.meta.postgres.PostgresDatabase</name>
            <inputSchema>public</inputSchema>
        </database>
        <target>
            <packageName>com.example.jooq.generated</packageName>
            <directory>target/generated-sources/jooq</directory>
        </target>
    </generator>
</configuration>
  1. Виконати генерацiю моделей:

mvn jooq-codegen:generate

Пiсля цього у вашому проектi з'являться Java-класи для кожної таблицi, представлення або процедури з бази даних.

Виконання CRUD-операцiй

jOOQ надає простi та елегантнi методи для виконання CRUD-операцiй:

  • Insert

ctx.insertInto(USERS)
   .columns(USERS.NAME, USERS.AGE)
   .values("John", 25)
   .execute();
  • Update

ctx.update(USERS)
   .set(USERS.AGE, 30)
   .where(USERS.ID.eq(1))
   .execute();
  • Delete

ctx.deleteFrom(USERS)
   .where(USERS.AGE.lt(18))
   .execute();

Складнi запити

Завдяки DSL ви можете створювати складнi SQL-запити, включаючи джойни, агрегацiї та вiконнi функцiї:

// SQL: SELECT name, COUNT(*) FROM users GROUP BY name HAVING COUNT(*) > 1
Result<Record2<String, Integer>> result = ctx.select(USERS.NAME, DSL.count())
                                             .from(USERS)
                                             .groupBy(USERS.NAME)
                                             .having(DSL.count().gt(1))
                                             .fetch();

result.forEach(record -> {
    System.out.println("Name: " + record.get(USERS.NAME) + ", Count: " + record.get(DSL.count()));
});

Що відбувається під час генерації класів у jOOQ?

  1. З'єднання з базою через JDBC
    jooq-codegen встановлює з'єднання з базою даних через JDBC за допомогою параметрів у конфігураційному файлі (URL, користувач, пароль).

  2. Отримання метаінформації
    Використовуючи стандартний інтерфейс JDBC DatabaseMetaData, jOOQ зчитує структуру бази даних:

    • Список таблиць (getTables)

    • Інформацію про колонки кожної таблиці (getColumns)

    • Первинні та зовнішні ключі (getPrimaryKeys, getImportedKeys)

    • Індекси та унікальні ключі (getIndexInfo)

    • Представлення (views) та збережені процедури (stored procedures).

  3. Моделювання схеми
    На основі отриманої метаінформації jOOQ будує внутрішню модель схеми бази даних. Наприклад, кожна таблиця представляється як об'єкт, який містить свої колонки, ключі, типи даних, і залежності між таблицями.

  4. Генерація Java-кодів
    Використовуючи модель схеми, jooq-codegen створює Java-класи для кожної таблиці:

    • Клас TableImpl, який описує таблицю.

    • Поля класу (TableField), які відповідають колонкам таблиці (з урахуванням типів даних).

    • Методики для роботи з первинними/зовнішніми ключами та індексами.

      Наприклад, для таблиці users з колонками id, name, age генеруються такі класи:

public class Users extends TableImpl<Record> {
    public static final TableField<Record, Integer> ID = createField("id", SQLDataType.INTEGER, this);
    public static final TableField<Record, String> NAME = createField("name", SQLDataType.VARCHAR, this);
    public static final TableField<Record, Integer> AGE = createField("age", SQLDataType.INTEGER, this);
}
  1. Зв'язки між таблицями
    Якщо таблиці мають зовнішні ключі, jOOQ генерує відповідні методи для роботи з ними. Наприклад, якщо orders має зовнішній ключ на users, то Users отримає методи для роботи з цим зв'язком.

  2. Анотації
    У випадку, якщо використовується Javax Validation чи JPA, jOOQ може згенерувати відповідні анотації (наприклад, @NotNull для обов'язкових колонок).

Цей процес дає готову структуру класів, яка відображає вашу базу, і дозволяє писати SQL-запити у типобезпечний спосіб.

Переваги використання jOOQ

  • Типобезпечнiсть. Помилки SQL ловляться ще на етапi компiляцiї.

  • Контроль. Ви пишете SQL-запити так, як вам потрiбно, без магiї ORM.

  • Продуктивнiсть. Автоматизацiя рутинних задач, як-от генерацiя моделей.

  • Пiдтримка багатьох СУБД. PostgreSQL, MySQL, Oracle, SQL Server тощо.

  • Гнучкiсть. Вiд CRUD до складних аналітичних запитiв.

А що по N+1, до прикладу?

Є така штучька в роботі з базами через Hibernate — проблема N+1 запитів. Це та ситуація, коли ви виконуєте один запит, щоб отримати список основних об'єктів, а потім робите ще N додаткових запитів для кожного з них. Звучить дико, але це реальність, якщо не розуміти, що відбувається під капотом.

Давайте розглянемо типовий приклад: є таблиці users і orders. Потрібно витягнути список користувачів разом із їхніми замовленнями.

Hibernate і N+1

Припустимо, у нас є такі класи:

@Entity
public class User {
    @Id
    private Long id;
    private String name;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
}

@Entity
public class Order {
    @Id
    private Long id;
    private String productName;

    @ManyToOne
    private User user;
}

Запит для отримання користувачів виглядає так:

List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class).getResultList();

users.forEach(user -> {
    System.out.println("User: " + user.getName());
    user.getOrders().forEach(order -> {
        System.out.println("Order: " + order.getProductName());
    });
});

Що відбудеться?

  1. Hibernate виконує 1 запит, щоб отримати всіх користувачів:

SELECT id, name FROM users;
  1. Потім для кожного користувача виконується додатковий запит, щоб завантажити його замовлення:

SELECT id, product_name FROM orders WHERE user_id = ?;

Якщо у вас 100 користувачів, то це буде 1 + 100 запитів. Супер, нє?

Як це виправити в Hibernate?

Звісно, можна оптимізувати, використовуючи JOIN FETCH:

List<User> users = entityManager
    .createQuery("SELECT u FROM User u JOIN FETCH u.orders", User.class)
    .getResultList();

users.forEach(user -> {
    System.out.println("User: " + user.getName());
    user.getOrders().forEach(order -> {
        System.out.println("Order: " + order.getProductName());
    });
});

Hibernate виконає один запит із JOIN, який одразу завантажить усі потрібні дані:

SELECT u.id, u.name, o.id, o.product_name 
FROM users u 
LEFT JOIN orders o ON u.id = o.user_id;

Це вирішує проблему N+1, але... Не завжди хочеться "танцювати" з HQL, особливо якщо запити складні.

jOOQ і N+1

З jOOQ все значно простіше. Ви контролюєте SQL, тому проблема N+1 навіть не виникає. Одразу робимо запит із JOIN і отримуємо всі дані:

Result<Record> result = ctx.select(USERS.ID, USERS.NAME, ORDERS.ID, ORDERS.PRODUCT_NAME)
                           .from(USERS)
                           .leftJoin(ORDERS).on(USERS.ID.eq(ORDERS.USER_ID))
                           .fetch();

Map<Integer, List<Record>> userOrdersMap = result.intoGroups(USERS.ID);

// Виводимо результати
userOrdersMap.forEach((userId, records) -> {
    System.out.println("User: " + records.get(0).get(USERS.NAME)); // Ім'я користувача
    records.forEach(record -> {
        System.out.println("Order: " + record.get(ORDERS.PRODUCT_NAME)); // Замовлення
    });
});

Що відбувається тут?

  • Ви самі пишете SQL із LEFT JOIN.

  • Дані одразу приходять у зручному форматі: користувачі з усіма замовленнями.

  • Немає зайвих запитів чи прихованих операцій.

SELECT 
    users.id, 
    users.name, 
    orders.id, 
    orders.product_name
FROM 
    users
LEFT JOIN 
    orders ON users.id = orders.user_id;

Як буде виглядати repo шар з використанням JOOQ?

Структура репозиторію

@Repository
public class UserRepository {
    private final DSLContext ctx;

    @Autowired
    public UserRepository(DSLContext ctx) {
        this.ctx = ctx;
    }

    public List<User> findAllUsers() {
        return ctx.select(USERS.ID, USERS.NAME, USERS.AGE)
                  .from(USERS)
                  .fetchInto(User.class);
    }

    public Optional<User> findById(Long userId) {
        return ctx.select(USERS.ID, USERS.NAME, USERS.AGE)
                  .from(USERS)
                  .where(USERS.ID.eq(userId))
                  .fetchOptionalInto(User.class);
    }

    public Long createUser(User user) {
        return ctx.insertInto(USERS)
                  .set(USERS.NAME, user.getName())
                  .set(USERS.AGE, user.getAge())
                  .returning(USERS.ID)
                  .fetchOne()
                  .getValue(USERS.ID);
    }

    public int updateUser(Long userId, User user) {
        return ctx.update(USERS)
                  .set(USERS.NAME, user.getName())
                  .set(USERS.AGE, user.getAge())
                  .where(USERS.ID.eq(userId))
                  .execute();
    }

    public int deleteUser(Long userId) {
        return ctx.deleteFrom(USERS)
                  .where(USERS.ID.eq(userId))
                  .execute();
    }

    public List<User> findUsersWithOrders() {
        return ctx.select(USERS.ID, USERS.NAME, USERS.AGE, ORDERS.ID.as("orderId"), ORDERS.PRODUCT_NAME.as("productName"))
                  .from(USERS)
                  .leftJoin(ORDERS).on(USERS.ID.eq(ORDERS.USER_ID))
                  .fetchGroups(USERS.ID)
                  .values()
                  .stream()
                  .map(records -> {
                      User user = records.get(0).into(User.class);
                      user.setOrders(records.stream()
                              .map(record -> record.into(Order.class))
                              .toList());
                      return user;
                  })
                  .toList();
    }
}

Опис методів

Опис методів

  1. findAllUsers Отримує список усіх користувачів. Дані мапляться безпосередньо в POJO User через метод fetchInto.

  2. findById Знаходить користувача за його ID. Використовується fetchOptionalInto, щоб одразу повернути Optional<User>.

  3. createUser Створює нового користувача і повертає його ID. Метод returning дозволяє одразу отримати значення первинного ключа.

  4. updateUser Оновлює інформацію про користувача за його ID.

  5. deleteUser Видаляє користувача за його ID.

  6. findUsersWithOrders Отримує список користувачів разом із їхніми замовленнями (вирішує проблему N+1). Тут використовується метод fetchGroups для групування даних за ID користувачів.


Доповідь по JOOQ

Презенташка з доповіді

Приклади коду з використанням JOOQ

Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Oleksandr Klymenko
Oleksandr Klymenko@overpathz

Java Software Engineer

4.9KПрочитань
1Автори
75Читачі
На Друкарні з 19 квітня

Більше від автора

  • Secure networking. Deep Dive

    Глибоке занурення в протоколи TLS/SSL та інфраструктуру відкритих ключів (PKI). Основні поняття, процес встановлення захищеного з'єднання, роль сертифікатів та ланцюжка довіри

    Теми цього довгочиту:

    Security
  • Поширені помилки у дизайні REST API

    У довгочиті розглядаються поширені помилки при проектуванні REST API та способи їх уникнення: версіонування, використання DTO, підхід CQRS, робота з мікросервісами, та інші практики для підвищення продуктивності, безпеки й зручності API

    Теми цього довгочиту:

    Java
  • Java. Короткий огляд еволюції багатопотоковості

    У перших версіях Java багатопоточність реалізовувалася за допомогою класу Thread, який дозволяв створювати нові потоки. Проте ця модель мала багато недоліків:

    Теми цього довгочиту:

    Java

Вам також сподобається

  • Рівні ізоляції транзакцій у БД

    Доволі детальний огляд аномалій у БД, рівнів ізоляції, які дозволяються уникнути аномалії, та імплементації цих рівнів. Багато використовую джерела та свої коментарі, в кінці декілька чит-шитів.

    Теми цього довгочиту:

    Бази Даних
  • Аспектно орієнтоване програмування в Java

    Стаття про детальний огляд AOP в Java. Weaving: CTW, LTW, RTW. Способи використання. Порівняння інструментів, пояснення анотацій, конфігурування, термінологія.

    Теми цього довгочиту:

    Java

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Вам також сподобається

  • Рівні ізоляції транзакцій у БД

    Доволі детальний огляд аномалій у БД, рівнів ізоляції, які дозволяються уникнути аномалії, та імплементації цих рівнів. Багато використовую джерела та свої коментарі, в кінці декілька чит-шитів.

    Теми цього довгочиту:

    Бази Даних
  • Аспектно орієнтоване програмування в Java

    Стаття про детальний огляд AOP в Java. Weaving: CTW, LTW, RTW. Способи використання. Порівняння інструментів, пояснення анотацій, конфігурування, термінологія.

    Теми цього довгочиту:

    Java