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}") // connected=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 моделями.

1
Задача
Kotlin SELF, 59 уровень, 1 лекция
Недоступна
Проверка соединения
Проверка соединения
1
Задача
Kotlin SELF, 59 уровень, 1 лекция
Недоступна
Поиск по почте
Поиск по почте
1
Задача
Kotlin SELF, 59 уровень, 1 лекция
Недоступна
Список расходов
Список расходов
1
Задача
Kotlin SELF, 59 уровень, 1 лекция
Недоступна
Чистка выполненных
Чистка выполненных
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ