1. Навіщо Exposed і чому це не ORM
Коли ви вже написали кілька JDBC-методів, швидко з’являється відчуття, ніби ви працюєте не програмістом, а копіювальною машиною. Усюди prepareStatement, усюди індекси параметрів, усюди однакові try/use. І ще треба не забути, що індексація параметрів починається з 1. У цей момент Exposed виглядає як порятунок: «можна писати запити котлінівською». Але важливо не потрапити в ілюзію, ніби 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 не може бути порожнім" }
require(amountCents > 0) { "amountCents має бути додатним" }
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 разів щось перепитаю в користувача», транзакція стає надто довгою, і ви самі собі створюєте проблеми. Намагайтеся тримати всередині транзакції лише операції над БД і мінімальну пов’язану обробку результату.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ