Вступление

Несколько лет назад мы осознали, что острая проблема Java это ее классические библиотеки для backend-разработки и решили что-то с этим сделать. На типовом проекте ~50% кода это работа с базой данных. В процессе решения задачи мы опирались на определенные метрики, которые (спойлер) на самом деле и определяют возможные решения. Метрики брались не с потолка и мотивированы тремя точками зрения:

  • так как мы сами пишем много софта, мы смотрели на технологию с позиции разработчика, который устал от существующих решений

  • с точки зрения бизнеса, стоимость решения задачи на TrueSql должна быть дешевле в несколько раз, а может и на порядок

  • как люди с высоким чувством прекрасного, для нас был важен высокий технический уровень реализации и совместимости

Метрики:

  • размер реализации технологии -> min

  • размер полной документации -> min

  • количество абстракций, с которыми будет взаимодействовать программист -> min

  • оверхед библиотеки по производительности -> min

  • удобство отладки -> max

  • качество, устойчивость, предсказуемость -> max

  • безопасность -> max

  • количество кода в приложении с TrueSql -> min

  • проверки на этапе компиляции -> max

  • устранение мапперов, лишних уровней “архитектуры”, генерация выходных DTO

Мы начали решать задачу Java-библиотеки для работы с базой данных с создания micro-ORM, однако в процессе оказалось что никакая ORM библиотека не сможет удовлетворить наши метрики. Также мы публиковали статью о проблемах самой популярной на платформе Java библиотеки для работы с БД. Поэтому наше решение это НЕ ORM (определение в статье) и НЕ QueryBuilder. Нам было очень приятно, что многие поддерживают нашу позицию. И мы сделали максимум чтобы TrueSql был ультимативным решением. Встречайте TrueSql – the ultimate database connector.

TrueSql - основные возможности

  • ResultSet to DTO mapping. Grouped object-tree fetching

  • Compile-time query validation

  • DTO generation

  • Null-safety

  • Full featured:

    • Generated keys

    • Update count

    • Batching

    • Transactions and connection pinning

    • Streaming fetching

    • Stored procedure call

    • Unfold parameters for "in-clause"

  • Multiple database schemas in module

  • Extra type bindings

  • DB constraint violation checks

  • 100% sql-injection safety guarantee

  • Exceptional performance. Equal to JDBC

Можно не поверить, но весь этот функционал укладывается в документацию на 12 минут чтения, но сейчас мы покажем конфигурацию и фетчинг.

Базовый API

Рассмотрим Hello, word на TrueSql

// ! ANNOTATE YOUR CLASS WITH @TrueSql !
@TrueSql class Main {
    
    // 1. declare DataSourceW or ConnectionW as connection configuration
    @Configuration(
        checks = @CompileTimeChecks(
            url = "jdbc:postgresql://localhost:5432/test_db",
            username = "user",
            password = "userpassword"
        )	
    ) static class PgDs extends DataSourceW {
        public PgDs(DataSource w) { super(w); }
    }

    void main() {
        // 2. open connection pool
        var ds = new PgDs(new HikariDataSource() {{
            setJdbcUrl("jdbc:postgresql://localhost:5432/test_db");
            setUsername("user");
            setPassword("userpassword");
        }});

        // 3. do querying
        var name = ds.q("select name from users where id = ?", 42)
            .fetchOne(String.class);
    }
} 

Данный пример (один файл) является самодостаточным. Важно, что вся конфигурация осуществляется в Java коде. Наконец-то мы можем писать Java-программы на Java! Теперь вам не нужно искать в подвалах интернета какие же ключи подкинуть в yml файл – все скажет IDE.

Конфигурация состоит из двух частей: этапа компиляции (через аннотации) и этапа исполнения через override методов DataSourceW.

На шаге 1 в аннотации @Configuration мы задаем параметры подключения к базе для проверки запросов на этапе компиляции (для CI/CD их можно будет переопределить через env).

На шаге 3 мы делаем простой запрос к базе. Аннотация @TrueSql включает для этого файла процессор аннотаций, который и проверяет запросы. Все, больше никаких аннотаций в API нет.

Рассмотрим простые примеры.

Вставка новых строк:

var id = ds.q(
   "insert into owners values(default, ?, ?, ?, ?, ?)",
   firstName, lastName, address, city, telephone
).asGeneratedKeys("id").fetchOne(int.class);

На этапе компиляции будет проверено:

  • Корректность запроса (полная, сервером СУБД)

  • Правильность типов переданных аргументов

  • Выходная колонка id (generated keys) действительно присутствует

  • Тип результата равен типу колонки id

Tree-select:

Основная проблема интерфейса работы с реляционной базой данных заключается в том, что результат это всегда некоторая таблица. По этой причине требуется дальнейшая предобработка для выдачи этих данных на frontend. В итоге классический jdbc-way не прижился на типовых Java бэкэндах. TrueSql решает эту проблему:

record Pet(int id, String name) {}
record Owner(
   int id, String firstName, String lastName,
   List<Pet> pets
) {}


var owner = ds.q("""
   select
       o.id, o.first_name, o.last_name,
       p.id, p.name
   from owners o
       left join pets p on o.id = p.owner_id
   where o.id = ?""", id
).fetchOneOrZero(Owner.class);

Мы сразу загрузили Owner’а со списком ассоциированных с ним Pet’ов. Данная Dto уже готова к отправке на frontend. По DTO TrueSql автоматически определяет поля для группировки и агрегации (на любое количество уровней). Если делать это руками, то пришлось бы написать примерно подобный код (P.S часть кода, которую сгенерировал TrueSql):

Код маппинга
var mapped = Stream.iterate(
   rs, t -> {
       try {
           return t.next();
       } catch (SQLException e) {
           throw source.mapException(e);
       }
   }, t -> t
).map(t -> {
   try {
       return
           new Row (
               new net.truej.sql.bindings.IntegerReadWrite().get(rs,1),
               new net.truej.sql.bindings.StringReadWrite().get(rs,2),
               new net.truej.sql.bindings.StringReadWrite().get(rs,3),
               new net.truej.sql.bindings.IntegerReadWrite().get(rs,4),
               new net.truej.sql.bindings.StringReadWrite().get(rs,5)
           );
   } catch (SQLException e) {
       throw source.mapException(e);
   }
})
.collect(
   java.util.stream.Collectors.groupingBy(
       r -> new G1(
           r.c1,
           r.c2,
           r.c3
       ), java.util.LinkedHashMap::new, Collectors.toList()
   )
).entrySet().stream()
.filter(g1 ->
   java.util.Objects.nonNull(g1.getKey().c1) ||
   java.util.Objects.nonNull(g1.getKey().c2) ||
   java.util.Objects.nonNull(g1.getKey().c3)
).map(g1 ->
   new com.example.demo.api.OwnersApi.Owner3(
       EvenSoNullPointerException.check(g1.getKey().c1),
       g1.getKey().c2,
       g1.getKey().c3,
       g1.getValue().stream().filter(r ->
           java.util.Objects.nonNull(r.c4) ||
           java.util.Objects.nonNull(r.c5)
       ).map(r ->
           new com.example.demo.api.OwnersApi.Pet3(
               EvenSoNullPointerException.check(r.c4),
               r.c5
           )
       ).distinct().toList()
   )
);

Заметим, что TrueSql генерирует оптимальный эквивалентный jdbc-код, а значит TrueSql имеет лучший runtime-performance по сравнению с другими библиотеками.

Tree-select и точка G режим:

И даже этого нам было мало! Наша задача окончательно закрыть вопрос с boilerplate!

import demo.api.OwnersApiG.*;


var owner = ds.q("""
   select
       o.id,
       o.first_name               ,
       o.last_name                ,
       p.id         as "Pet pets.",
       p.name       as "    pets."
   from owners o
       left join pets p on o.id = p.owner_id
   where o.id = ?""", id
).g.fetchOneOrZero(Owner.class);

TrueSql может сам сгенерировать DTO (создать классы Owner и Pet) исходя из тела любого запроса. ДАЖЕ ЕСЛИ ВАМ НУЖНЫ ГРУППИРОВКИ!!! Группы размечаются в алиасах к именам колонок (as):

p.id         as "Pet pets.",

p.name       as "    pets."

Сгенерированные DTO
public static class Pet {
   @NotNull public final int id;
   @Nullable public final java.lang.String name;


   public Pet(
       int id,
       java.lang.String name
   ) {
       this.id = id;
       this.name = name;
   }
   @Override public boolean equals(Object other) {
       return this == other || (
           other instanceof Pet o &&
           java.util.Objects.equals(this.id, o.id) &&
           java.util.Objects.equals(this.name, o.name)
       );
   }


   @Override public int hashCode() {
       int h = 1;
       h = h * 59 + java.util.Objects.hashCode(this.id);
       h = h * 59 + java.util.Objects.hashCode(this.name);
       return h;
   }
}
public static class Owner {
   @NotNull public final int id;
   @Nullable public final java.lang.String firstName;
   @Nullable public final java.lang.String lastName;
   public final List<Pet> pets;


   public Owner(
       int id,
       java.lang.String firstName,
       java.lang.String lastName,
       List<Pet> pets
   ) {
       this.id = id;
       this.firstName = firstName;
       this.lastName = lastName;
       this.pets = pets;
   }
   @Override public boolean equals(Object other) {
       return this == other || (
           other instanceof Owner o &&
           java.util.Objects.equals(this.id, o.id) &&
           java.util.Objects.equals(this.firstName, o.firstName) &&
           java.util.Objects.equals(this.lastName, o.lastName) &&
           java.util.Objects.equals(this.pets, o.pets)
       );
   }


   @Override public int hashCode() {
       int h = 1;
       h = h * 59 + java.util.Objects.hashCode(this.id);
       h = h * 59 + java.util.Objects.hashCode(this.firstName);
       h = h * 59 + java.util.Objects.hashCode(this.lastName);
       h = h * 59 + java.util.Objects.hashCode(this.pets);
       return h;
   }
}

Это невероятно круто. И вопрос стоит гораздо глубже чем избавление от “лишнего кода”, но об этом потом.

Композиция

var discounts = List.of(
   new DateDiscount(
       LocalDate.of(2024, 7, 1),
        new BigDecimal("0.2")
   ),
   new DateDiscount(
       LocalDate.of(2024, 8, 1),
       new BigDecimal("0.15")
   )
);

ds.q(
       discounts, """
           update bill b
           set discount = amount * ?
           where cast(b.date as date) = ?""",
       v -> new Object[]{v.discount, v.date}
   )
   .asGeneratedKeys("id", "discount")
   .withUpdateCount
   .g.fetchList(Discount.class);

var expected = new UpdateResult<>(
   new long[]{2L, 2L},
   List.of(
       new Discount(1L, new BigDecimal("20.00")),
       new Discount(2L, new BigDecimal("20.00")),
       new Discount(3L, new BigDecimal("15.00")),
       new Discount(4L, new BigDecimal("15.00"))
   )
);

batch update + generated keys + update count + .g !!!

Api TrueSql имеет АДСКИ-ХОРОШИЕ возможности композиции. За это была уплочена высокая цена — месяц ежедневного трехразового употребления пуэра.

Хотелось бы рассказать и про силу modifying CTE / unfold, и про простоту работы с транзакциями и про многие другие ультимативности, но об этом в рамках других статей.

Нам говорили: это невозможно в Java

Как это работает? А все просто – Java 5 annotaion processors дают нам возможность генерировать классы (dto и jdbc-код делающий fetch). Спецификация JDBC 1.0 от января 1997 года позволяет получать метаданные запроса не исполняя его (PreparedStatement.{getMetadata(), getParametersMetadata()}). Современные версии баз данных хорошо поддерживают этот API. Ну и конечно TrueSql работает потому что мы вложили большую часть себя и МНОГО ДУМАЛИ чтобы обеспечить композицию и сделать все правильно.

х/ф Назад в будущее
х/ф Назад в будущее

Поддержка баз данных

TrueSql поддерживает любую базу данных у которой есть jdbc-драйвер. Для этих драйверов в TrueSql дополнительно реализован слой совместимости, исправляющий несоответствие спецификации: PostgreSQL, MySQL, MSSQL, Oracle, MariaDB, HSQL.

Покрытие тестами

TrueSql имеет 100% test coverage (bytecode), весь API имеет тесты.

Размер реализации

Размер реализации с тестами занимает 10k LOC против, например, 1.3M LOC Hibernate Core. Чтение всей документации занимает 12 минут.

Выигрыш в количестве кода

Кодовая база типового проекта (spring-pet-clinic), написанного на TrueSql, меньше в 4 раза по сравнению с типичными кодовыми базами на Hibernate / Spring Data JPA!

Лицензия

Наша цель это TrueSql на каждом Java-проекте. Мы могли бы пойти по коммерческой модели JOOQ, но тогда все бы продолжили страдать. Единственный способ получить market share и забыть про все это ORM-безумие на Java платформе – дать современное решение по бесплатной лицензии. Поэтому TrueSql распространяется под лицензией Apache 2.0.

Что это значит для меня?

Ночь прошла настало утро. TrueSql подходит для любого проекта с перечисленными СУБД. Неважно кто вы - разработчик, CTO или владелец IT-компании - теперь у вас есть TrueSql. Уже с сегодняшнего дня вы можете писать выразительный, быстрый, недорогой и качественный код.

Java-community
Java-community

Документация, сайт и следующие статьи

Документацию к библиотеке и исходный код вы можете найти тут: https://github.com/pain64/true-sql. В каталоге sample находятся примеры проектов на gradle и maven с использованием TrueSql и Spring Web.

Сайт проекта рекомендуется к посещению всем и особенно заинтересовавшимся: https://truej.net/.

В следующих статьях мы рассмотрим все:

  • Все возможности TrueSql

  • Философия дизайна TrueSql

  • Философия точки G в контексте TrueSql

  • Бенчмарки

  • Почему с TrueSql можно писать в 4 раза меньше кода

  • Экономика выбора TrueSql

Комментарии (9)


  1. PastorGL
    10.02.2025 09:55

    Хмм... Любой уважающий себя Java-разработчик обязан написать как минимум 1 ORM/JDBC-коннектор, 1 контейнер/DI/AOP, 1 кодогенератор/интерпретатор/компилятор. А лучше 5. Это единственный нормальный путь становления сеньором — и других наверное, нет.

    Если вам удалось в одном проекте покрыть сразу несколько перечисленных категорий, значит, вы всё делаете правильно, и станете хорошим Java-сеньором. А если у вас хватает наглости выложить его в паблик, и добавить в название true/ultimate, то вы имеете все шансы стать просто отличным Java-сеньором. Большинство почему-то стесняется :)

    Но хватит похвалы. Подобного DO layers написано очень много — я лично штук 6 имплементаций видел, заточенных под конкретный проект, и сам как минимум три писал (ещё более специализированных). У всех один недостаток: если надо сделать шаг влево или вправо от видения автора, то сразу приплыли. Сколько ни думай головой, заранее всех кейсов не продумаешь.

    Но за выкладку в паблик зачёт, конечно.


  1. panzerfaust
    10.02.2025 09:55

    Не очень понимаю восторги. У нас в 2 проектах точно такие же штуки написаны. В одном попроще, в другом чуть посложнее. Аннотаций, кстати, нет ни там ни там. Где попроще - там самая увесистая часть это маппер из кортежей в объекты на 370 строк.

    У нас запросы к БД простецкие, для них такой колхоз пойдет. Как общее решение для вообще любого проекта - да ну, вы шутите. При живом-то JOOQ.


    1. PastorGL
      10.02.2025 09:55

      Оно редко за 2~3к строк вылазит, если только генератора триггеров или ещё какой-нибудь эзотерической фигни не требуется.

      А на современной жабе с рекордами писать такое вообще считай читерство. Не нужно, как во времена 6, извращаться с рефлексией.


  1. Andrey_Solomatin
    10.02.2025 09:55

    Для большей трушности можно еще добавить github workflow для релиза, и для солидности добавить тесты на других версиях джава https://github.com/pain64/true-sql/blob/main/.github/workflows/gradle.yml#L28.


    1. goodfup Автор
      10.02.2025 09:55

      Спасибо за предложение, и за то что ознакомились с проектом. Пока пароли от maven central храним на отдельной машине. По поводу версий Java, то основная версия это 21+. Во многом, потому что было самим очень удобно реализовать TrueSql на свежей джаве. Бэкпорты на 17 и ниже стоят в планах, но тут ждем запроса от вендоров


  1. johndow
    10.02.2025 09:55

    Я просто оставлю это здесь: https://jdbi.org/


    1. goodfup Автор
      10.02.2025 09:55

      Приветствую!

      Во время проектирования TrueSql, jdbi тоже изучался. Из принципиальных вещей там нет:

      • Проверок запросов на этапе компиляции

      • Генерации выходных dto

      • Древовидных выборок, которые полностью меняют ситуацию с отчетами и rest-контроллерами

      • И много чего там еще нет или сделано неправильно


  1. DenSigma
    10.02.2025 09:55

    Как описывается маппинг от таблицы/запроса к полям dto (бизнес-объекта)? С условием, что dto нельзя засирать аннотациями? Имеется отдельный слой persistence, в методы которого входят/выходят dto.


    1. goodfup Автор
      10.02.2025 09:55

      Приветствую!

      record User(long id, String name) {}

      ds.q("select id, name from users").fetchList(User.class)

      Шаг 1. TrueSql, в классе dto (User) ожидает конструктор с ненулевым количество параметров. Если его нет, или их несколько - ошибка компиляции

      Шаг 2. Генерирует маппер вида new User(rs.getLong(1), rs.getString(2))

      Шаг 3. Если включена проверка запросов, то проверяется соответствие выходных колонок запроса сигнатуре конструктора (количество параметров, типы)

      А в режиме генерации dto:

      ds.q("select id, name from users").g.fetchList(User.class)

      TrueSql сам создаст класс User. Имена полей получит путем преобразования имен выходных колонок из snake_case в camelCase