null

Exposed DSL - изучаем и применяем (часть 1)

Введение

В этой статье рассмотрим фреймворк для работы с реляционными СУБД, разработанный под авторством JetBrains. А также изучим его особенности на простых примерах. В конце постараюсь ответить на вопрос о применимости и эффективности для быстрой разработки приложений на Kotlin.

Официальный ресурс - https://www.jetbrains.com/exposed/

Фреймворк представляет собой лёгкую SQL-библиотеку, обеспечивающую типобезопасный DSL для построения запросов и опциональный DAO-уровень. Exposed не полагается на аннотации или XML: таблицы и колонки описываются обычным Kotlin-кодом, который по структуре напоминает SQL DDL. Благодаря этому код остаётся читаемым и наглядным: даже не знакомый с Exposed разработчик может понять схему базы по коду классов-таблиц.

Exposed активно развивается и уже сейчас поддерживает широкий набор СУБД: PostgreSQL, MySQL/MariaDB, SQLite, H2, Oracle, Microsoft SQL Server и другие. Поддержка нескольких вариантов драйверов (JDBC и реактивного R2DBC) даёт гибкость: в типичных приложениях используется блокирующий JDBC, а там, где требуются асинхронные потоки, можно подключить R2DBC.

Настройка и подключение к базе данных

Для начала работы в проект добавляются модули exposed-core, exposed-jdbc (для JDBC) и, опционально, exposed-dao (для ORM-стиля). После этого создается подключение к БД. Например, для SQLite это может быть:

Database.connect("jdbc:sqlite:data.db", driver = "org.sqlite.JDBC")

Exposed сохраняет параметры подключения и автоматически выбирает нужный диалект SQL для текущей СУБД. Как только соединение настроено, все действия с БД выполняются внутри транзакций. При выполнении кода внутри блока transaction { ... }. Exposed автоматически начинает транзакцию, выполняет операции и фиксирует её по успешному завершению или откатывает при исключении. Таким образом гарантируется целостность данных: если в транзакционном блоке что-то пошло не так, все изменения откатятся автоматически.

Определение схемы (таблицы и колонки)

Таблицы описываются наследованием от класса Table (или специализированных, например IntIdTable для автогенерируемого ID). В объекте-таблице задают колонки как свойства: тип, имя, ограничения. Например:

object ChannelConfigurations : Table("channel_config") {
    val linkId = long("channel_link_id")
    val name = varchar("channel_name", 128)
    val owner = long("owner_id")
    val rephrasePrompt = text("rephrase_prompt").nullable()
    // Составной первичный ключ:
    override val primaryKey = PrimaryKey(owner, linkId)
}

В этом примере видно, что DSL-определение очень похоже на SQL DDL: задаются имена и типы колонок, первичный ключ и т.д. И никаких аннотаций или рефлексии не требуется. Exposed автоматически получает имя таблицы из названия объекта (можно переопределить, передав строку в конструктор Table(...)).

С Exposed удобно работать с внешними ключами: связь задаётся вызовом references(). Например, чтобы добавить внешний ключ user_id к таблице сообщений:

object Messages : Table("messages") {
    val id = long("id").autoIncrement().primaryKey()
    val fromUser = long("from_user_id").references(Users.id, onDelete = ReferenceOption.CASCADE)
    val toUser   = long("to_user_id").references(Users.id, onDelete = ReferenceOption.CASCADE)
    val content = text("content")
}

Это указывает, что from_user_id и to_user_id ссылаются на Users.id, с каскадным удалением. Такие связи всегда типобезопасны: компилятор проверит соответствие типов колонок. В Exposed есть также короткая форма reference("col", TableName), когда нужно сослаться на первичный ключ другой таблицы без указания типа.

Для первичных ключей Exposed можно использовать IntIdTable или LongIdTable, где колонка id создаётся автоматически, что избавляет от бойлерплейта.

Наконец, после описания объектов-таблиц их нужно создать в базе. Для этого применяется SchemaUtils.create(). Пример:

transaction {
    SchemaUtils.create(ChannelConfigurations)
}

Этот вызов создаст таблицу (если её ещё нет) по схеме, описанной в объекте. SchemaUtils умеет создавать несколько таблиц за раз или только недостающие колонки. Это удобно на этапе разработки; в продакшене схемы часто управляются миграциями (Flyway, Liquibase), поэтому при старте приложения обычно только проверяют схему.

Автоматическая генерация схемы

В своем проекте для тестирования и быстрого редеплоя я написал небольшую утилиту, которая автоматически сканирует все классы таблиц и создает для них отображение в базе данных. Я использую аннотацию, для того, чтобы пометить нужные мне таблицы. Приведу код ниже, может быть он вам пригодится.

//Аннотация для пометки энтити
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Schema

 

object DatabaseFactory {
    fun init() {
        Database.connect("jdbc:sqlite:data.db", driver = "org.sqlite.JDBC")

        val classLoader = Thread.currentThread().contextClassLoader
        val reflections = Reflections(
            ConfigurationBuilder()
                .addClassLoaders(classLoader)
                .addUrls(classLoader.getResources("").toList())
                .setScanners(Scanners.TypesAnnotated)
        )

        val annotatedClasses = reflections.getTypesAnnotatedWith(Schema::class.java)
        val tables = annotatedClasses.mapNotNull { cls ->
            try {
                val obj = cls.kotlin.objectInstance
                if (obj is Table) obj else null
            } catch (e: Exception) { null }
        }

        transaction {
            SchemaUtils.create(*tables.toTypedArray())
        }
    }
}

 

Запросы и операции (DSL API)

После того как схема определена и соединение установлено, Exposed позволяет выполнять запросы через типобезопасный DSL. Все операции должны быть в транзакции (transaction { ... } или для корутин –  newSuspendedTransaction).

Вставка (INSERT):

val newId = transaction {
    ChannelConfigurations.insert {
        it[linkId] = 123L
        it[name] = "Example"
        it[owner] = 42L
    } get ChannelConfigurations.owner
}

Метод insert { ... } принимает лямбду, где через it[...] задаются значения колонок. Результатом можно получить сгенерированный ключ.

Выборка (SELECT):

val config = transaction {
    ChannelConfigurations.select { ChannelConfigurations.linkId eq 123L }
        .map { row ->
            row[ChannelConfigurations.name]
        }
}

Метод select { ... } возвращает объект Query, по которому можно итерироваться или собрать результаты. Колонки доступны по индексатору row[Table.column]. Результат – коллекция объектов ResultRow. Обычно их преобразуют в бизнес-объекты: в примере выше можно сопоставить их с data class, с помощью маппера toChannelConfigurationDto().

Обновление и удаление: также реализуются через методы update { ... } и deleteWhere { ... } с условиями DSL. Благодаря типобезопасности компилятора, при построении условий проверяется корректность имен колонок и типов данных. Например:

ChannelConfigurations.update({ owner eq 42L }) {
    it[name] = "Новое имя"
}

Сложные запросы: Exposed поддерживает JOIN, агрегаты, подзапросы, группы и т.д. Для сложных JOIN-операций часто используются псевдонимы таблиц (alias). Например, если нужна самоссылка или несколько разных связей к одной таблице, создают алиас:

val u1 = Users.alias("u1")
val u2 = Users.alias("u2")
Messages.join(u1, JoinType.INNER, Messages.fromUser, u1[Users.id])
        .join(u2, JoinType.INNER, Messages.toUser,   u2[Users.id])
        .slice(Messages.content, u1[Users.name], u2[Users.name])
        .selectAll()

В этом примере alias("...") создаёт копию таблицы с указанным именем для SQL. В результате row[u1[Users.name]] извлечёт нужное поле из этой «псевдо-таблицы». Такой подход особенно важен при самоссылках и сложных JOIN.

Преимущества Exposed DSL

  • Типобезопасность и защищённость. DSL Exposed «привязан» к Kotlin: названия таблиц и колонок проверяются на этапе компиляции. Это исключает опечатки в именах, а параметры вставляются через API без строкового конкатенирования, что защищает от SQL-инъекций.
  • Лёгковесность и модульность. Exposed не навязывает сложной инфраструктуры: вы используете только те модули, которые нужны – например, только exposed-core и exposed-jdbc для простых запросов. Он быстрее и «проще» Hibernate по количеству кода, и строится вокруг Kotlin, а не Java-рефлексии.
  • Универсальность и поддержка СУБД. Exposed работает с разными базами (PostgreSQL, MySQL, SQLite, Oracle и др.) посредством одних и тех же конструкций.
  • Гибкость запросов. DSL позволяет писать и простые CRUD-операции, и сложные JOIN/агрегации, не уходя в сырые SQL-строки. Благодаря расширяемым методам код остаётся читабельным.
  • Интеграция с Kotlin. Exposed хорошо вписывается в экосистему Kotlin: легко использовать корутины, inline-функции, лямбды.

В итоге Exposed оставляет открытыми руки разработчику: вы получаете баланс между прозрачностью SQL и удобством языка. Он идеально подходит для сервисов и ботов, где нужно быстро и безопасно взаимодействовать с базой, не выписывая всю логику «в лоб» на JDBC.

Недостатки и особенности

  • Нет «автоматической» ORM. Exposed предоставляет только легковесный DAO-слой, но он требует явных классов сущностей. В основном Exposed используется как DSL, и не создаёт ваши data-классы. Это даёт гибкость, но лишает некоторого удобства «вшитых» ORM типа JPA.
  • Лишний код для DAO. Если вы всё же используете DAO-уровень, объявления сущностей могут быть довольно многословными. Некоторые считают это громоздким по сравнению с классами JPA.
  • Ручные миграции. В самой библиотеке нет системы миграций: для версионирования схем используются внешние инструменты.

Заключение

Exposed DSL — это современный подход к работе с базой на Kotlin. Он сочетает в себе гибкость SQL и надёжность языка. Благодаря типобезопасному DSL код запросов компактнее и безопаснее «сырых» JDBC-запросов, но при этом разработчик остаётся в контроле, не теряя понимания структуры данных.

Фреймворк идеален для приложений средней сложности и микросервисов, где важна скорость разработки и типобезопасность. Крупномасштабные корпоративные системы могут предпочесть более «тяжёлую» Hibernate-подобную экосистему, но для типичного Kotlin-разработчика Exposed часто оказывается более естественным выбором. Его преимущества – легковесность, удобство и высокая степень интеграции – очевидны при создании бота или сервиса на Kotlin.

Вперед