SQLDelight для Kotlin: полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

SQLDelight для Kotlin: полное руководство с примерами

1. Что это и зачем нужна

SQLDelight — это библиотека для Kotlin, которая генерирует типобезопасный (type-safe) Kotlin-код из SQL-запросов. Она была разработана компанией Cash App (Square) и поддерживает Kotlin Multiplatform (KMP), что позволяет использовать один и тот же код для работы с базами данных на Android, iOS, JVM, JS и Native платформах.

Основная идея SQLDelight заключается в том, что вы пишете SQL-запросы в .sq файлах, а библиотека автоматически генерирует Kotlin-классы, интерфейсы и функции, которые полностью соответствуют вашим запросам. Это устраняет необходимость писать шаблонный код для маппинга результатов запросов в объекты и обрабатывать SQL-инъекции на уровне компиляции.

Зачем использовать SQLDelight? Во-первых, она обеспечивает полную проверку типов на этапе компиляции: если вы измените структуру таблицы, компилятор сразу укажет на все места, где нужно обновить запросы. Во-вторых, она поддерживает миграции, транзакции, асинхронные запросы и работу с несколькими платформами. В-третьих, она интегрируется с популярными фреймворками, такими как Ktor, Koin, Dagger/Hilt и другими.

2. Установка

SQLDelight устанавливается через систему сборки Gradle. Для Kotlin Multiplatform добавьте следующий плагин в корневой build.gradle.kts:

// Корневой build.gradle.kts
plugins {
    id("app.cash.sqldelight") version "2.0.2" apply false
}

// В модуле (например, :shared)
plugins {
    id("app.cash.sqldelight")
}

sqldelight {
    databases {
        create("MyDatabase") {
            packageName.set("com.example.db")
        }
    }
}

dependencies {
    implementation("app.cash.sqldelight:coroutines-extensions:2.0.2")
    implementation("app.cash.sqldelight:runtime:2.0.2")
    // Для Android
    implementation("app.cash.sqldelight:android-driver:2.0.2")
    // Для iOS
    implementation("app.cash.sqldelight:native-driver:2.0.2")
    // Для JVM
    implementation("app.cash.sqldelight:sqlite-driver:2.0.2")
}

После синхронизации Gradle создайте файл src/commonMain/sqldelight/com/example/db/MyDatabase.sq (путь должен совпадать с packageName).

3. Быстрый старт — минимальный рабочий пример

Создайте файл MyDatabase.sq:

-- MyDatabase.sq
CREATE TABLE Person (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    age INTEGER NOT NULL
);

selectAll:
SELECT * FROM Person;

insertPerson:
INSERT INTO Person(name, age) VALUES (?, ?);

Теперь в Kotlin-коде:

import com.example.db.MyDatabase
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver

fun main() {
    val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:test.db")
    MyDatabase.Schema.create(driver)
    
    val database = MyDatabase(driver)
    val queries = database.myDatabaseQueries
    
    // Вставка
    queries.insertPerson("Alice", 30)
    queries.insertPerson("Bob", 25)
    
    // Выборка
    val people = queries.selectAll().executeAsList()
    people.forEach { person ->
        println("${person.name} is ${person.age} years old")
    }
}

Этот код создаст таблицу, вставит две записи и выведет их.

4. Основные методы/функции/классы

4.1 executeAsList()

Сигнатура: fun <T> executeAsList(): List<T>

Что делает: Выполняет SELECT-запрос и возвращает все строки результата в виде списка объектов сгенерированного типа.

val allPeople: List<Person> = queries.selectAll().executeAsList()

4.2 executeAsOne()

Сигнатура: fun <T> executeAsOne(): T

Что делает: Выполняет запрос и возвращает единственную строку. Если строк больше одной или нет ни одной — выбрасывает исключение.

val person: Person = queries.selectById(1).executeAsOne()

4.3 executeAsOneOrNull()

Сигнатура: fun <T> executeAsOneOrNull(): T?

Что делает: Выполняет запрос и возвращает единственную строку или null, если строк нет.

val person: Person? = queries.selectById(999).executeAsOneOrNull()

4.4 execute()

Сигнатура: fun execute(): Long

Что делает: Выполняет запрос на изменение данных (INSERT, UPDATE, DELETE) и возвращает количество затронутых строк.

val rowsAffected: Long = queries.deletePerson(1).execute()

4.5 transaction()

Сигнатура: fun <T> transaction(block: Transaction.() -> T): T

Что делает: Выполняет блок кода в рамках транзакции. Если блок выбрасывает исключение, транзакция откатывается.

database.transaction {
    queries.insertPerson("Charlie", 35)
    queries.insertPerson("Diana", 28)
}

4.6 Queries.awaitAsList() (корутины)

Сигнатура: suspend fun <T> awaitAsList(): List<T>

Что делает: Асинхронный вариант executeAsList() для использования с корутинами. Требует подключения модуля coroutines-extensions.

lifecycleScope.launch {
    val people = queries.selectAll().awaitAsList()
    // обновляем UI
}

4.7 MyDatabase.Schema.create()

Сигнатура: fun create(driver: SqlDriver)

Что делает: Создает все таблицы и индексы, определенные в .sq файлах. Вызывается при первом запуске или при обновлении схемы.

MyDatabase.Schema.create(driver)

4.8 MyDatabase.Schema.migrate()

Сигнатура: fun migrate(driver: SqlDriver, oldVersion: Int, newVersion: Int)

Что делает: Выполняет миграции между версиями схемы. Версии задаются в .sq файлах с помощью комментариев -- 1.0.

// В .sq файле:
-- 1.0
CREATE TABLE Person (...);

-- 2.0
ALTER TABLE Person ADD COLUMN email TEXT;

// В коде:
MyDatabase.Schema.migrate(driver, 1, 2)

4.9 addListener() / removeListener()

Сигнатура: fun addListener(listener: Query.Listener): Unit

Что делает: Позволяет подписаться на изменения результатов запроса. Полезно для реактивного обновления UI.

val query = queries.selectAll()
val listener = object : Query.Listener {
    override fun queryResultsChanged() {
        // обновляем данные
    }
}
query.addListener(listener)
// ...
query.removeListener(listener)

4.10 asFlow() (корутины + Flow)

Сигнатура: fun <T> asFlow(): Flow<List<T>>

Что делает: Преобразует запрос в Kotlin Flow, который эмитит новые результаты при каждом изменении данных.

val peopleFlow: Flow<List<Person>> = queries.selectAll().asFlow().mapToList()
peopleFlow.collect { people ->
    // обновляем UI
}

5. Реальный практический пример

Рассмотрим приложение для управления задачами (Todo list) с поддержкой категорий и статусов. Создадим файл TodoDatabase.sq:

-- TodoDatabase.sq

-- 1.0
CREATE TABLE Category (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    color TEXT NOT NULL DEFAULT '#FFFFFF'
);

CREATE TABLE Task (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    is_completed INTEGER NOT NULL DEFAULT 0,
    category_id INTEGER,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (category_id) REFERENCES Category(id)
);

-- Запросы для категорий
selectAllCategories:
SELECT * FROM Category;

insertCategory:
INSERT INTO Category(name, color) VALUES (?, ?);

updateCategory:
UPDATE Category SET name = ?, color = ? WHERE id = ?;

deleteCategory:
DELETE FROM Category WHERE id = ?;

-- Запросы для задач
selectAllTasks:
SELECT * FROM Task ORDER BY created_at DESC;

selectTasksByCategory:
SELECT * FROM Task WHERE category_id = ? ORDER BY created_at DESC;

selectTasksByStatus:
SELECT * FROM Task WHERE is_completed = ? ORDER BY created_at DESC;

insertTask:
INSERT INTO Task(title, description, category_id) VALUES (?, ?, ?);

updateTaskStatus:
UPDATE Task SET is_completed = ? WHERE id = ?;

deleteTask:
DELETE FROM Task WHERE id = ?;

-- Сложный запрос с JOIN
selectTasksWithCategory:
SELECT t.id, t.title, t.description, t.is_completed, t.created_at, c.name AS category_name, c.color AS category_color
FROM Task t
LEFT JOIN Category c ON t.category_id = c.id
ORDER BY t.created_at DESC;

Теперь реализуем класс TodoRepository:

import com.example.db.TodoDatabase
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapToList

class TodoRepository(driver: SqlDriver) {
    private val database = TodoDatabase(driver)
    private val queries = database.todoDatabaseQueries

    init {
        TodoDatabase.Schema.create(driver)
    }

    // Категории
    fun getAllCategories(): List<Category> = queries.selectAllCategories().executeAsList()

    fun addCategory(name: String, color: String) {
        queries.insertCategory(name, color)
    }

    fun updateCategory(id: Long, name: String, color: String) {
        queries.updateCategory(name, color, id)
    }

    fun deleteCategory(id: Long) {
        queries.deleteCategory(id)
    }

    // Задачи
    fun getAllTasks(): List<Task> = queries.selectAllTasks().executeAsList()

    fun getTasksByCategory(categoryId: Long): List<Task> = queries.selectTasksByCategory(categoryId).executeAsList()

    fun getTasksByStatus(completed: Boolean): List<Task> = queries.selectTasksByStatus(if (completed) 1 else 0).executeAsList()

    fun addTask(title: String, description: String?, categoryId: Long?) {
        queries.insertTask(title, description, categoryId)
    }

    fun toggleTaskStatus(taskId: Long, completed: Boolean) {
        queries.updateTaskStatus(if (completed) 1 else 0, taskId)
    }

    fun deleteTask(taskId: Long) {
        queries.deleteTask(taskId)
    }

    // Сложный запрос
    fun getTasksWithCategory(): List<SelectTasksWithCategory> = queries.selectTasksWithCategory().executeAsList()

    // Реактивный вариант с Flow
    fun observeAllTasks(): Flow<List<Task>> = queries.selectAllTasks().asFlow().mapToList()

    // Транзакция — добавить категорию и задачу
    fun addCategoryWithTask(categoryName: String, categoryColor: String, taskTitle: String, taskDescription: String?) {
        database.transaction {
            val categoryId = queries.insertCategory(categoryName, categoryColor).execute()
            queries.insertTask(taskTitle, taskDescription, categoryId)
        }
    }
}

// Использование
fun main() {
    val driver = JdbcSqliteDriver("jdbc:sqlite:todos.db")
    val repo = TodoRepository(driver)

    // Добавляем категории
    repo.addCategory("Работа", "#FF5733")
    repo.addCategory("Личное", "#33FF57")

    // Добавляем задачи
    repo.addTask("Написать отчёт", "Подготовить квартальный отчёт", 1)
    repo.addTask("Купить продукты", "Молоко, хлеб, яйца", 2)

    // Выводим все задачи с категориями
    repo.getTasksWithCategory().forEach { task ->
        println("${task.title} [${task.category_name}] - ${if (task.is_completed == 1L) "✓" else "✗"}")
    }

    // Переключаем статус
    repo.toggleTaskStatus(1, true)

    // Реактивное наблюдение
    repo.observeAllTasks().collect { tasks ->
        println("Текущие задачи: ${tasks.size}")
    }
}

6. Советы по производительности и типичные ошибки

Советы по производительности

  • Используйте индексы: Добавляйте индексы на часто запрашиваемые поля (например, category_id, is_completed) в .sq файлах.
  • Избегайте N+1 запросов: Используйте JOIN вместо отдельных запросов для связанных данных

Рекомендаций