1. Навіщо нам JDBC і чому він виглядає як «три матрьошки»
Коли ви вперше бачите JDBC-код, він часто здається дивним: чому, щоб просто «дістати список витрат», потрібно відкрити зʼєднання, потім створити statement, далі отримати result set, а тоді ще й кудись рухати курсор… Насправді JDBC — це низькорівневий стандартний API для SQL у світі JVM. Він чесно відображає реальність: база даних — це зовнішній ресурс, запит — окрема операція, а результат — потік рядків.
Якщо запамʼятати «три матрьошки», усе помітно спрощується:
- Connection — «я підʼєднався до бази, у мене є канал звʼязку».
- PreparedStatement — «я підготував SQL-запит (із параметрами) і готовий його виконати».
- ResultSet — «я отримав табличний результат і читаю рядки по одному».
Щоб було зовсім наочно, ось схема життєвого циклу запиту:
flowchart TD
A["DriverManager.getConnection(...)"] --> B["Connection"]
B --> C["prepareStatement(sql)"]
C --> D["PreparedStatement"]
D -->|SELECT| E["executeQuery() → ResultSet"]
D -->|INSERT/UPDATE/DELETE| F["executeUpdate() → Int"]
E --> G["rs.next() → читаємо колонки"]
G --> H["Закриваємо ResultSet / Statement / Connection"]
Головна думка лекції: ви навчитеся писати JDBC-код так, щоб він був (1) безпечним, (2) читабельним і (3) без витоків ресурсів.
2. Connection: підʼєднання і робота з ресурсами
Зʼєднання з базою даних — це не «звичайний Kotlin-обʼєкт», який можна створити й забути. Воно тримає зовнішні ресурси: сокети, файлові дескриптори, блокування, внутрішні структури драйвера. Тому Connection обовʼязково потрібно закривати — навіть якщо вам здається, що «програма маленька і все одно ось-ось завершиться». Саме так і зʼявляються баги, які проявляються лише «за годину в продакшені».
Мінімальний скелет підʼєднання зазвичай виглядає так (покажу на SQLite, бо це найпростіший варіант для прикладів):
import java.sql.DriverManager
fun main() {
val url = "jdbc:sqlite:app.db"
DriverManager.getConnection(url).use { conn ->
println("connected=${!conn.isClosed}") // підключено=true
}
}
Зверніть увагу на use(). Вона робить важливу річ: гарантує close() наприкінці блока. Це саме той випадок, коли Kotlin допомагає не забути закрити ресурс. Ідіома use() якраз і рекомендована для AutoCloseable-ресурсів, щоб не писати вручну finally { close() }.
Невелика заувага про URL
У навчальних прикладах ми триматимемо url рядком у коді. У реальному проєкті його зазвичай передають через конфіг або змінні середовища. Але це вже питання інфраструктури, а не «ядра» JDBC.
3. PreparedStatement: параметри замість конкатенації
Якщо ви колись хотіли зробити так:
val sql = "SELECT id FROM users WHERE email = '$email'"
то JDBC (і ваша майбутня карʼєра) мʼяко просить вас зупинитися й подумати. Конкатенація рядків у SQL — це і про баги з лапками, і про проблеми з екрануванням, і про ризик SQL-інʼєкцій.
Правильний підхід: SQL — фіксований, а значення передаються через параметри ?.
import java.sql.Connection
fun findUserIdByEmail(conn: Connection, email: String): Long? {
val sql = "SELECT id FROM users WHERE email = ?"
conn.prepareStatement(sql).use { st ->
st.setString(1, email) // індексація параметрів починається з 1!
st.executeQuery().use { rs ->
return if (rs.next()) rs.getLong("id") else null
}
}
}
Тут уже видно «дві матрьошки»: statement і result set також закриваються через use().
Чому індексація параметрів починається з 1
Тому що JDBC — давній, як легенди про таблички Excel без автозбереження. Це історичне рішення API: перший параметр — індекс 1. Не 0. Не питайте. Просто прийміть це як погоду.
4. executeQuery() і executeUpdate()
У JDBC є дуже практичний поділ: одні запити повертають таблицю рядків (SELECT), інші — просто змінюють дані (INSERT/UPDATE/DELETE). Тож і методи різні.
Для SELECT використовуємо executeQuery() й отримуємо ResultSet.
import java.sql.Connection
fun countUsers(conn: Connection): Int {
val sql = "SELECT COUNT(*) AS cnt FROM users"
conn.prepareStatement(sql).use { st ->
st.executeQuery().use { rs ->
rs.next()
return rs.getInt("cnt")
}
}
}
Для INSERT/UPDATE/DELETE використовуємо executeUpdate() й отримуємо число змінених рядків.
import java.sql.Connection
fun deleteUser(conn: Connection, id: Long): Int {
val sql = "DELETE FROM users WHERE id = ?"
conn.prepareStatement(sql).use { st ->
st.setLong(1, id)
return st.executeUpdate() // 0 або 1 (в ідеалі)
}
}
Кількість змінених рядків — це ваш мінімальний «датчик адекватності». Якщо ви очікували видалити один запис, а видалили 17 — це привід насторожитися.
5. ResultSet: курсор, next() і мапінг у data class
ResultSet — це не список. Це «курсор» за результатом. На початку він стоїть перед першим рядком, і доки ви не викликали rs.next(), читати колонки не можна. Це одна з найчастіших помилок початківців: «чому в мене все null/падає?».
Приклад: читаємо один запис
import java.sql.Connection
data class User(val id: Long, val email: String, val name: String)
fun findUser(conn: Connection, email: String): User? {
val sql = "SELECT id, email, name FROM users WHERE email = ?"
conn.prepareStatement(sql).use { st ->
st.setString(1, email)
st.executeQuery().use { rs ->
return if (!rs.next()) null else User(
id = rs.getLong("id"),
email = rs.getString("email"),
name = rs.getString("name")
)
}
}
}
Тут є тонкий момент: rs.getString(...) у світі Kotlin — це platform type (String!). Тобто Kotlin дозволить присвоїти його в String, але якщо в БД насправді лежить NULL, ви можете отримати сюрпризи пізніше. Для рівня початківців достатньо правила: якщо колонка може бути NULL, зберігайте її як String?.
Приклад: читаємо багато рядків у список
Нехай у нас навчальний застосунок — консольний трекер витрат (ми його вже «уявно» будували в попередніх темах про шарову архітектуру). Додаймо модель витрати:
data class Expense(
val id: Long,
val amountCents: Long,
val category: String,
val note: String?
)
Тепер прочитаємо список витрат:
import java.sql.Connection
fun listExpenses(conn: Connection): List<Expense> {
val sql = "SELECT id, amount_cents, category, note FROM expenses ORDER BY id DESC"
conn.prepareStatement(sql).use { st ->
st.executeQuery().use { rs ->
val result = mutableListOf<Expense>()
while (rs.next()) {
result += Expense(
id = rs.getLong("id"),
amountCents = rs.getLong("amount_cents"),
category = rs.getString("category"),
note = rs.getString("note") // нехай буде nullable за змістом
)
}
return result
}
}
}
Тут видно два «закони ResultSet»:
- щоб прочитати рядок — спочатку next(),
- щоб прочитати багато рядків — while (rs.next()).
Колонка за назвою і за індексом
Можна читати і так, і так: rs.getLong(1) або rs.getLong("id"). За індексом зазвичай трохи швидше, але для навчання й читабельності варіант за назвою майже завжди кращий. Так менше шансів переплутати порядок колонок, коли SQL змінюється.
6. Закриття ресурсів: use {} і читабельність
JDBC-обʼєкти майже завжди є ресурсами: Connection, PreparedStatement, ResultSet. Якщо їх не закривати, ви отримаєте витоки. Іноді вони проявляються швидко, а іноді — за 10–30 хвилин. Це найгірший жанр багів: «працює, доки не перестає».
Старий підхід: try/finally
Іноді корисно розуміти механіку «вручну», щоб краще цінувати use().
import java.sql.Connection
import java.sql.PreparedStatement
fun closeQuietly(st: PreparedStatement?) {
try { st?.close() } catch (_: Exception) {}
}
fun example(conn: Connection) {
val st = conn.prepareStatement("SELECT 1")
try {
st.executeQuery()
} finally {
closeQuietly(st)
}
}
Працює, але виглядає як міні-ритуал.
Kotlin-підхід: use {} і вкладеність
Kotlin рекомендує use() для AutoCloseable, щоб закриття відбувалося автоматично.
import java.sql.DriverManager
fun pingDb(url: String): Int {
return DriverManager.getConnection(url).use { conn ->
conn.prepareStatement("SELECT 1 AS one").use { st ->
st.executeQuery().use { rs ->
rs.next()
rs.getInt("one")
}
}
}
}
Так, це «три use підряд». Зате це надійно: навіть якщо запит упаде з винятком, ресурси буде закрито у зворотному порядку.
Як зробити читабельніше, не втрачаючи безпеки
Коли вкладеність починає різати око, зазвичай допомагають невеликі функції-обгортки. Наприклад, функція «виконай із зʼєднанням»:
import java.sql.Connection
import java.sql.DriverManager
inline fun <T> withConnection(url: String, block: (Connection) -> T): T {
return DriverManager.getConnection(url).use { conn ->
block(conn)
}
}
Використання:
fun isAlive(url: String): Boolean =
withConnection(url) { conn -> !conn.isClosed }
Ми не чіпаємо тут жодних «тем на майбутнє». Просто застосовуємо вже знайомі функції та лямбди, щоб зробити JDBC-код менш «драбинчастим».
7. Шар зберігання: JdbcExpenseRepository без магії
Важливо не лише вміти написати запит, а й уміти не розмазати JDBC по всьому застосунку. У попередніх архітектурних темах ми вже обговорювали ідею меж: CLI/сценарії не повинні знати деталей зберігання. Тут ми зробимо невеликий репозиторій, який сховає JDBC-нутрощі.
Почнімо з інтерфейсу (зовнішній світ бачитиме лише його):
interface ExpenseRepository {
fun add(amountCents: Long, category: String, note: String?): Long
fun listLatest(limit: Int): List<Expense>
}
Тепер — реалізація. У цій лекції ми не заглиблюємося в транзакції та «отримання згенерованого id» для всіх баз (це окрема тема, і в драйверів бувають нюанси). Тож зробимо простий навчальний варіант: метод add поверне 1L як «успіх».
import java.sql.DriverManager
class JdbcExpenseRepository(private val url: String) : ExpenseRepository {
override fun add(amountCents: Long, category: String, note: String?): Long {
val sql = "INSERT INTO expenses(amount_cents, category, note) VALUES (?, ?, ?)"
DriverManager.getConnection(url).use { conn ->
conn.prepareStatement(sql).use { st ->
st.setLong(1, amountCents)
st.setString(2, category)
st.setString(3, note)
st.executeUpdate()
return 1L // для простоти: «успіх»
}
}
}
override fun listLatest(limit: Int): List<Expense> {
val sql = """
SELECT id, amount_cents, category, note
FROM expenses
ORDER BY id DESC
LIMIT ?
""".trimIndent()
return DriverManager.getConnection(url).use { conn ->
conn.prepareStatement(sql).use { st ->
st.setInt(1, limit)
st.executeQuery().use { rs ->
val items = mutableListOf<Expense>()
while (rs.next()) {
items += Expense(
id = rs.getLong("id"),
amountCents = rs.getLong("amount_cents"),
category = rs.getString("category"),
note = rs.getString("note")
)
}
items
}
}
}
}
}
Тут ми використовуємо LIMIT ? як параметр. Це зручно: запит лишається фіксованим, і вам не потрібно збирати SQL конкатенацією.
Мінітаблиця: типи в JDBC і часті промахи
Коли початківець починає мапити рядки в data class, він часто плутається: getInt чи getLong? setString чи setObject? Чи можна писати null? Щоб зменшити хаос, тримайте під рукою невелику таблицю відповідностей «на щодень».
| SQL-сенс (спрощено) | PreparedStatement.set... | ResultSet.get... | Коментар |
|---|---|---|---|
| ціле число | |
|
якщо сума/ID може бути великим — частіше беруть Long |
| текст | |
|
getString може повернути null, якщо в БД NULL |
| nullable значення | |
|
у Kotlin краще зберігати як String?, якщо поле необовʼязкове |
Якщо ви відчуваєте, що типи «пливуть», корисно мати просте правило: у коді застосунку тримайте стабільні типи (Long для грошей у центах, String для категорії), а SQL підлаштовуйте під них.
8. Типові помилки під час роботи з JDBC
Помилка №1: SQL через конкатенацію замість параметрів (?).
Коли значення «вклеюють» у SQL-рядок, ви отримуєте проблеми з лапками, екрануванням і потенційно відчиняєте двері SQL-інʼєкціям. Навіть якщо ви впевнені, що «користувач не злий», злим може виявитися формат даних. Звичка використовувати PreparedStatement і setString/setLong/... розвʼязує це на рівні техніки, а не на рівні надії.
Помилка №2: забули, що параметри нумеруються з 1, і поставили setString(0, ...).
Це класика. JDBC живе за правилом «перший параметр — індекс 1». У результаті з нулем ви отримаєте виняток — часто в найбільш невідповідний момент. Допомагає проста дисципліна: тримайте set...() прямо поруч із SQL, щоб візуально збігалися порядок ? і порядок set.
Помилка №3: викликали rs.getLong(...) до rs.next().
ResultSet починається «перед першим рядком». Доки ви не викликали next(), поточного рядка не існує. Для одного запису використовуйте патерн if (rs.next()) { ... } else null, для списку — while (rs.next()) { ... }. Це не смаківщина, це контракт API.
Помилка №4: переплутали executeQuery() і executeUpdate().
Якщо ви викликаєте executeQuery() для INSERT, драйвер може лаятися, а може поводитися дивно. Тримайте просте правило: SELECT → executeQuery() (і ResultSet), INSERT/UPDATE/DELETE → executeUpdate() (і Int — кількість рядків). Це робить код передбачуваним і полегшує перевірку результату.
Помилка №5: не закрили ресурси (або закрили лише Connection, але забули ResultSet).
У JDBC ресурсів багато, і кожен може тримати зовнішні штуки. Якщо ви закрили зʼєднання, але забули закрити ResultSet, драйвер може тримати «хвости» довше, ніж вам хотілося б. Найпростіший і найнадійніший стиль у Kotlin — вкладені use() для всіх JDBC-обʼєктів, які ви створюєте. Це і є причина, чому use() так цінується для AutoCloseable.
Помилка №6: вважаєте, що getString() «точно не null», бо у вас у Kotlin тип String.
JDBC-методи з Java часто виглядають для Kotlin як platform types, і компілятор не завжди змусить вас обробити null. Якщо колонка в БД допускає NULL, не зображайте героя: зберігайте поле в моделі як String? і обробляйте null явно. Це дешевше, ніж відлагоджувати NPE за тиждень.
Помилка №7: тримаєте JDBC-код у main і розмазуєте SQL по всьому застосунку.
Перші кілька запитів так «швидше». Але далі ви отримуєте проєкт, де зміни схеми вимагають шукати SQL у десяти файлах. Набагато спокійніше виділяти шар зберігання: репозиторій/DAO, який єдиний знає про JDBC, а решта коду працює з методами add, listLatest і вашими моделями data class.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ