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 | |
|
| SELECT | |
|
| Транзакция | |
|
И да: мы всё ещё обязаны думать про транзакции, про целостность данных и про то, что 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 и маппинг ResultRow → Expense
Чтение данных — самый частый 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 | Возвращает |
|---|---|---|
| Добавить | |
обычно InsertStatement (мы его игнорируем) |
| Прочитать | |
набор строк, обычно маппим в List<T> |
| Обновить | |
Int (сколько строк обновили) |
| Удалить | |
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 API (с Entity) — это конкретный 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 раз спрошу что-нибудь у пользователя», транзакция становится длинной по времени, и вы сами себе создаёте проблемы. Старайтесь держать внутри транзакции только операции над БД и минимальную связанную обработку результата.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ