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 вместо отдельных запросов для связанных данных