JavaRush /Курсы /Kotlin SELF /Exposed: таблицы и CRUD через DSL

Exposed: таблицы и CRUD через DSL

Kotlin SELF
59 уровень , 3 лекция
Открыта

1. Зачем Exposed и почему это не ORM

Когда вы уже написали пару JDBC‑методов, появляется сильное чувство, что вы работаете не программистом, а копировальной машиной: везде prepareStatement, везде индексы параметров, везде одинаковые try/use, и ещё нужно не забыть, что индексация параметров начинается с 1. В этот момент Exposed выглядит как спасение: «можно писать запросы по‑Kotlin’овски». Но важно не попасть в иллюзию, что Exposed «отменяет SQL» — он просто делает его менее шумным.

Exposed — это библиотека от JetBrains, которая даёт Kotlin‑DSL для SQL. В рамках этой лекции мы будем говорить именно про Exposed DSL (иногда его называют SQL DSL), а не про Exposed DAO API с Entity/EntityClass. DAO‑часть существует, но она тянет за собой больше абстракций и магии, а у нас цель — понятный код для новичка: «вижу таблицу, вижу вставку, вижу выборку».

Чтобы почувствовать разницу, удобно держать в голове сравнение:

Что делаем JDBC (в лоб) Exposed DSL
INSERT
строка SQL + setString(1, …)
Table.insert { it[column] = value }
SELECT
ResultSet + rs.getLong("id")
select { … }.map { row -> … }
Транзакция
autoCommit=false + commit/rollback
transaction { … }

И да: мы всё ещё обязаны думать про транзакции, про целостность данных и про то, что UPDATE без WHERE — это «кнопка самоуничтожения». Просто теперь код будет выглядеть так, будто вы пишете Kotlin, а не «SQL в строках + шаманство вокруг ресурсов».

2. Подключение: зависимости и Database.connect

С Exposed есть один честный момент: чтобы «магия началась», библиотеку нужно подключить, а ещё нужен JDBC‑драйвер вашей БД. Exposed не заменяет драйвер: он поверх него. Поэтому настройка проекта — это два шага: зависимости + подключение к БД. Дальше вы уже пишете DSL‑код и не думаете о низкоуровневых деталях при каждом запросе.

Ниже — минимальный пример зависимостей (как концепция). Версии зависят от вашего проекта, поэтому воспринимайте это как «форма», а не как «единственно верные числа»:

// build.gradle.kts (идея)
dependencies {
    implementation("org.jetbrains.exposed:exposed-core:<version>")
    implementation("org.jetbrains.exposed:exposed-jdbc:<version>")

    implementation("org.xerial:sqlite-jdbc:<version>") // если используем SQLite
}

Подключение к БД в коде обычно делают один раз при старте приложения. Для учебного проекта возьмём SQLite и файл "app.db" рядом с программой.

import org.jetbrains.exposed.sql.Database

fun connectDb() {
    Database.connect(
        url = "jdbc:sqlite:app.db",
        driver = "org.sqlite.JDBC"
    )
}

Здесь важно запомнить простую мысль: Database.connect(...) — это «мы сказали Exposed, куда ходить». Никаких запросов это не выполняет, это просто настройка. А сами операции будут выполняться внутри transaction { ... }.

3. Описание таблицы: LongIdTable и колонки

До этого момента таблица у нас существовала как SQL‑строка CREATE TABLE .... В Exposed таблица становится Kotlin‑объектом, в котором мы объявляем колонки как свойства. Это удобно по двум причинам: во‑первых, IDE начинает помогать автодополнением (меньше опечаток в названиях колонок). Во‑вторых, вы получаете «типизацию» на уровне колонок: строку в Long вы случайно не засунете, компилятор вас остановит раньше базы.

Чтобы продолжать практический пример, представим, что мы развиваем наш консольный проект учёта расходов. У нас будет сущность Expense: сумма (в копейках/центах), категория и короткое описание.

data class Expense(
    val id: Long,
    val title: String,
    val amountCents: Long,
    val category: String
)

Теперь опишем таблицу. Для новичка самый дружелюбный вариант — LongIdTable: он уже содержит id с авто‑инкрементом и убирает часть рутины.

import org.jetbrains.exposed.dao.id.LongIdTable

object Expenses : LongIdTable("expenses") {
    val title = varchar("title", length = 200)
    val amountCents = long("amount_cents")
    val category = varchar("category", length = 50)
}

Выглядит почти как объявление структуры данных. И это отличный момент, чтобы зафиксировать: Exposed не хранит данные в этом объекте. object Expenses — это описание схемы, а не коллекция расходов в памяти.

Для наглядности можно представить это так:

flowchart TD
    A["object Expenses : LongIdTable(...)
описание колонок"] -->|DSL строит SQL| B["SQL запрос"] B --> C["JDBC driver"] C --> D["База данных (SQLite файл app.db)"]

4. Транзакции: transaction {} как рабочая рамка

Если JDBC‑код заставлял вас постоянно думать о autoCommit и аккуратном rollback, Exposed предлагает более приятную «рамку»: transaction { ... }. Внутри блока вы пишете запросы, а снаружи получаете понятный результат. Но тут есть подвох, о который часто спотыкаются: transaction {} — это не просто «синтаксический сахар», это граница, внутри которой гарантируется корректная работа Exposed с соединением и транзакцией.

Первое практическое действие, которое обычно нужно в учебном проекте — создать таблицы. Exposed умеет делать это через SchemaUtils.

import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

fun initSchema() {
    transaction {
        SchemaUtils.create(Expenses)
    }
}

Это «создай таблицу, если её нет» (в рамках возможностей конкретной БД). Да, это похоже на миграции, но миграции как отдельную дисциплину мы сегодня не трогаем — нам важно, чтобы проект просто работал.

Очень рекомендую привыкнуть к стилю: всё, что общается с БД — внутри transaction, а снаружи остаётся обычный Kotlin‑код. В этом смысле Exposed дисциплинирует архитектуру лучше, чем «JDBC везде где попало».

5. CRUD в Exposed DSL

Create: insert { ... }

Когда вы впервые видите insert в Exposed, он кажется «слишком красивым, чтобы быть правдой». На самом деле он просто строит параметризованный SQL‑запрос и выполняет его через JDBC. То есть безопасность и идея параметров никуда не исчезли — вы просто перестали писать ? и setString(1, ...) руками.

Добавим функцию создания расхода. Для простоты мы не будем возвращать id (это можно сделать, но это добавляет деталей, которые сейчас не критичны). Мы вернём Unit, а успех/ошибка в учебном проекте пока пусть выражается исключением.

import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction

fun addExpense(title: String, amountCents: Long, category: String) {
    transaction {
        Expenses.insert {
            it[Expenses.title] = title
            it[Expenses.amountCents] = amountCents
            it[Expenses.category] = category
        }
    }
}

Обратите внимание на форму it[Колонка] = значение. Здесь it — это DSL‑контекст построения INSERT.

Если хочется «чуть‑чуть по‑взрослому», можно добавить простую валидацию входных данных на уровне домена (мы это уже делали раньше через require/check), чтобы не отправлять в БД заведомо плохие значения:

fun addExpenseSafe(title: String, amountCents: Long, category: String) {
    require(title.isNotBlank()) { "title must not be blank" }
    require(amountCents > 0) { "amountCents must be positive" }

    addExpense(title.trim(), amountCents, category.trim())
}

Это не «фича Exposed», это дисциплина приложения: БД — не мусорное ведро, она и так много терпит.

Read: select и маппинг ResultRowExpense

Чтение данных — самый частый CRUD‑сценарий: показать список расходов, найти по категории, найти по id. В JDBC вы читали ResultSet курсором через rs.next(). В Exposed результат чтения вы получаете как набор строк ResultRow, и дальше обычно делаете маппинг в свои data class. Это ровно тот же подход, который мы применяли раньше к коллекциям: «получили набор элементов → преобразовали». Такой стиль хорошо ложится на стандартные операции коллекций вроде map.

Начнём с «прочитать всё». В Exposed это выглядит очень прямо:

import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction

fun listExpenses(): List<Expense> = transaction {
    Expenses.selectAll().map { row ->
        Expense(
            id = row[Expenses.id].value,
            title = row[Expenses.title],
            amountCents = row[Expenses.amountCents],
            category = row[Expenses.category]
        )
    }
}

Да, здесь есть .value. Это потому, что LongIdTable хранит id как EntityID<Long> (обёртка), а вашему домену обычно нужен обычный Long.

Теперь фильтрация, например по категории. Тут появляется eq (равно). Его удобно читать как «category equals …».

import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction

fun listByCategory(category: String): List<Expense> = transaction {
    Expenses.select { Expenses.category eq category }.map { r ->
        Expense(r[Expenses.id].value, r[Expenses.title], r[Expenses.amountCents], r[Expenses.category])
    }
}

Здесь есть важная «учебная» мысль: вы не должны отдавать наружу ResultRow. Это внутренний формат Exposed. Наружу лучше отдавать вашу доменную модель (Expense). Тогда остальной код не зависит от выбранной библиотеки работы с БД — это прямо в духе архитектурных границ, которые мы обсуждали ранее.

Update и Delete: update, deleteWhere и проверка строк

Изменение и удаление — место, где легко сделать ошибку, потому что «вроде бы код скомпилировался». В JDBC мы обсуждали, что executeUpdate() возвращает количество затронутых строк, и это нужно проверять. В Exposed ровно та же идея: update и deleteWhere возвращают Int — сколько строк реально поменялось/удалилось. Если вы ожидали 1, а получили 0, это не «ну ладно» — это сигнал, что запись не найдена или критерий неверный.

Сделаем обновление заголовка расхода по id:

import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update

fun renameExpense(id: Long, newTitle: String): Boolean = transaction {
    val updated = Expenses.update({ Expenses.id eq id }) {
        it[Expenses.title] = newTitle
    }
    updated == 1
}

Удаление по id:

import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.transactions.transaction

fun deleteExpense(id: Long): Boolean = transaction {
    val deleted = Expenses.deleteWhere { Expenses.id eq id }
    deleted == 1
}

Если вы заметили, здесь повторяется паттерн: «выполнили → сравнили с 1». Это не избыточность, это контракт вашего приложения: операция “rename/delete by id” должна затронуть ровно одну запись. Всё остальное — ошибка сценария.

Для красоты можно сделать маленькую таблицу‑шпаргалку, что что возвращает:

Операция Exposed DSL Возвращает
Добавить
insert { ... }
обычно InsertStatement (мы его игнорируем)
Прочитать
select { ... }
набор строк, обычно маппим в List<T>
Обновить
update({ ... }) { ... }
Int (сколько строк обновили)
Удалить
deleteWhere { ... }
Int (сколько строк удалили)

6. Где здесь DAO: прячем Exposed за репозиторием

Когда вы впервые подключаете Exposed, очень хочется писать transaction { Expenses.selectAll() ... } прямо из main. Оно же работает! Но через неделю вы поймёте, что ваш код превратился в «лапшу из транзакций», и любое изменение схемы ломает половину приложения. Поэтому нам нужна граница: место, где живёт доступ к данным, и место, где живёт логика сценариев. В прошлых архитектурных днях мы называли это репозиторием/слоем хранения; в терминах паттернов рядом стоит слово DAO.

DAO (Data Access Object) в прикладном смысле — это объект/класс/модуль, который знает, как читать и писать данные. Он скрывает детали SQL/Exposed и выдаёт наружу понятные методы вроде addExpense, listExpenses, deleteExpense.

Начнём с контракта репозитория. Это не обязательно должны быть generics, но сам принцип «контракт в виде интерфейса» отлично дружит с Kotlin‑типами и обобщениями, которые мы уже встречали в языке.

interface ExpenseRepository {
    fun add(title: String, amountCents: Long, category: String)
    fun listAll(): List<Expense>
    fun deleteById(id: Long): Boolean
}

Теперь реализация на Exposed:

import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction

class ExposedExpenseRepository : ExpenseRepository {
    override fun add(title: String, amountCents: Long, category: String) = transaction {
        Expenses.insert {
            it[Expenses.title] = title
            it[Expenses.amountCents] = amountCents
            it[Expenses.category] = category
        }
    }

    override fun listAll(): List<Expense> = transaction {
        Expenses.selectAll().map { r ->
            Expense(r[Expenses.id].value, r[Expenses.title], r[Expenses.amountCents], r[Expenses.category])
        }
    }

    override fun deleteById(id: Long): Boolean = transaction {
        Expenses.deleteWhere { Expenses.id eq id } == 1
    }
}

Да, тут чуть больше 10 строк — но это цельный кусок, который показывает идею «в одном месте лежит вся БД‑логика». В реальном проекте вы бы разбили на приватные функции и сделали покороче, но сейчас полезнее увидеть «скелет целиком».

Теперь main (или слой CLI) работает с ExpenseRepository, а не с Exposed напрямую:

fun main() {
    connectDb()
    initSchema()

    val repo: ExpenseRepository = ExposedExpenseRepository()
    repo.add("Кофе", 19900, "food")

    println(repo.listAll()) // [Expense(id=1, title=Кофе, amountCents=19900, category=food)]
}

Так «где тут DAO»? Вот он: ExposedExpenseRepository. Это и есть DAO/Repository в прикладном смысле. Вы спрятали Exposed внутрь, и остальной код вообще не обязан знать, что у вас там SQL‑таблицы.

Очень важно не путаться: Exposed DAO APIEntity) — это конкретный API Exposed. DAO как паттерн — это архитектурная роль. Сегодня мы говорим про паттерн и используем Exposed DSL как инструмент внутри него.

7. Типичные ошибки

Ошибка №1: выполнять запросы вне transaction {} и удивляться странным падениям.
Exposed устроен так, что большинство операций должны выполняться внутри транзакции, потому что именно там библиотека управляет соединением и контекстом выполнения. Если попытаться «чуть‑чуть селектнуть» снаружи, вы получите ошибки уровня “no transaction in context” или похожие сюрпризы. Лечится это не заклинаниями, а дисциплиной: любые операции чтения/записи — только в transaction.

Ошибка №2: тащить ResultRow наружу вместо доменной модели.
Поначалу кажется удобным вернуть из функции список ResultRow и «пусть вызывающий сам достанет поля». Но это мгновенно привязывает весь проект к Exposed. Через пару дней у вас row[Expenses.title] начнёт встречаться в слоях, где вообще не должно быть базы данных. Правильный стиль — маппинг в Expense прямо в репозитории и возврат чистых моделей. И да, map { ... } — ваш лучший друг, он же давно знаком по стандартной библиотеке.

Ошибка №3: не проверять результат update/deleteWhere.
Если вы обновляете или удаляете запись по id, а операция затронула 0 строк — это не “ну и ладно”, это «мы думали, что запись есть, а её нет». Если затронуло больше одной строки — это ещё хуже, значит условие неправильное. Привычка сравнивать результат с ожидаемым числом строк резко повышает надёжность программы.

Ошибка №4: размазывать transaction {} по всему коду, включая UI/CLI‑слой.
Самый распространённый архитектурный провал выглядит так: «я в main читаю команду, тут же открываю транзакцию, тут же печатаю в консоль». Это смешивает ответственность: слой хранения начинает жить в том же месте, где пользовательский сценарий. Гораздо проще поддерживать проект, если транзакции живут внутри репозитория/DAO, а наружу выходят методы уровня “add/list/delete”.

Ошибка №5: думать, что Exposed отменяет необходимость понимать SQL и схему.
Exposed делает код короче, но смысл операций остаётся SQL‑ным: SELECT читает, INSERT создаёт, UPDATE меняет, DELETE удаляет. Ограничения таблиц, ключи, уникальность и логика транзакций по‑прежнему важны. Если вы не понимаете, что делает запрос, то DSL не спасёт — он только сделает ошибку более элегантной на вид.

Ошибка №6: слишком много логики внутри transaction {}.
Транзакция должна быть короткой и связанной с согласованностью данных. Если внутрь попадает ввод пользователя, форматирование большого текста, долгие вычисления или «пойду‑ка я ещё 20 раз спрошу что-нибудь у пользователя», транзакция становится длинной по времени, и вы сами себе создаёте проблемы. Старайтесь держать внутри транзакции только операции над БД и минимальную связанную обработку результата.

1
Задача
Kotlin SELF, 59 уровень, 3 лекция
Недоступна
Старт заметок
Старт заметок
1
Задача
Kotlin SELF, 59 уровень, 3 лекция
Недоступна
Полка библиотеки
Полка библиотеки
1
Задача
Kotlin SELF, 59 уровень, 3 лекция
Недоступна
Фильтр заявок
Фильтр заявок
1
Задача
Kotlin SELF, 59 уровень, 3 лекция
Недоступна
Правка контакта
Правка контакта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ