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

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

Kotlin SELF
Рівень 59 , Лекція 3
Відкрита

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
рядок 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 не може бути порожнім" }
    require(amountCents > 0) { "amountCents має бути додатним" }

    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 разів щось перепитаю в користувача», транзакція стає надто довгою, і ви самі собі створюєте проблеми. Намагайтеся тримати всередині транзакції лише операції над БД і мінімальну пов’язану обробку результату.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ