JavaRush /Курси /Kotlin SELF /JDBC — Connection, PreparedStatement, ResultSet

JDBC — Connection, PreparedStatement, ResultSet

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

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»:

  1. щоб прочитати рядок — спочатку next(),
  2. щоб прочитати багато рядків — 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... Коментар
ціле число
setInt, setLong
getInt, getLong
якщо сума/ID може бути великим — частіше беруть Long
текст
setString
getString
getString може повернути null, якщо в БД NULL
nullable значення
setString(index, null)
getString → String?
у 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, драйвер може лаятися, а може поводитися дивно. Тримайте просте правило: SELECTexecuteQuery()ResultSet), INSERT/UPDATE/DELETEexecuteUpdate()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.

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