Eсть таблица:
CREATE TABLE person
(
id uuid primary key,
name text,
birth_date date
)
и соотвтетствующий ей дата-класс:
data class Person(
val id: UUID,
val name: String,
val birthDate: LocalDate,
)
Что если для того чтобы выполнить базовые CRUD операции:
сохранить список
Person
-оввычитать всё из таблицы
удалить все записи в таблице
найти по ID
удалить по имени
будет достаточно создать интерфейс:
@SqliteRepository
interface PersonRepository : Repository<People> {
fun saveAll(people: List<Person>)
fun selectAll(): List<Person>
fun deleteAll()
fun selectBy(id: UUID): Person?
fun deleteBy(name: String)
}
а имплеметнация будет сгенерирована автоматически.
Напоминает Spring Data? Но это не Spring, не Hibernate и даже не JPA.
TL;DR
Kotlin-центричная библиотека (не фреймворк)
Не ORM (не содержит JPA)
Генерирует SQL и JDBC до этапа компиляции (Kotlin Annotation Precessing)
Нет магии в рантайме
Сгенерированный код отформатирован, можно дебажить, работает навигация, можно скопировать в проект и модифицировать
Удобный DSL для работы с базой
Есть 2 имплементации: под Postgres и Sqlite
Конфигурация
На данный момент есть 2 реализации этой библиотеки: для Postgresql и Sqlite. В данной статье примеры будут для Sqlite.
Для начала нужно сконфигурировать Gradle (да простят меня пользователи Maven):
build.gradle.kts
plugins {
kotlin("kapt") version "1.4.31" //(1)
kotlin("plugin.serialization") version "1.4.31"
}
dependencies {
implementation("com.github.mfarsikov:kotlite-core:0.5.0") //(2)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0") //(3)
implementation("org.xerial:sqlite-jdbc:3.34.0") //(4)
kapt("com.github.mfarsikov:kotlite-kapt:0.5.0") //(5)
}
kapt {
arguments {
arg("kotlite.db.qualifiedName", "my.pkg.DB") //(6)
}
}
Пояснения по build.gradle.kts
Добавить плагин для обработки аннотаций и генерации кода (`kapt`).
Добавить зависимость на
core
-часть библиотеки. Она содержит необходимые аннотации, и некоторый обвязочный код.Сериализация в/из JSON используется для вложенных коллекций.
Непосредственно драйвер Sqlite базы.
Плагин создаст kapt конфигурацию, в которую нужно включить зависимость на `kapt`-часть библиотеки. Именно она занимается генерацией SQL запросов и кода JDBC.
Необходимо указать полное имя класса (включая пакет), через который мы будем работать с базой данных (этот класс также будет сгенерирован).
Репозиторий
import kotlite.annotations.SqliteRepository
@SqliteRepository
interface PersonRepository
От такого репозитория пользы немного, но уже для него Kotlite может сгенерировать имплементацию.
Команда ./gradlew kaptKotlin
сгенерирует:
build/generated/source/kapt/PersonRepositoryImpl.kt
@Generated
internal class PersonRepositoryImpl(
private val connection: Connection
) : PersonRepository
Первый запрос
import kotlite.annotations.Query
import kotlite.annotations.SqliteRepository
@SqliteRepository
interface PersonRepository {
@Query("SELECT id, name, birth_date FROM person")
fun findPeople(): List<Person>
}
Kotlite
знает достаточно, чтобы сгенерировать код для этого запроса:
Из возвращаемого типа
List
следует, что записей может быть от 0 до NИз возвращаемого типа
Person
следует, что каждый кортеж будет содержать три поля:id
,name
иbirth_date
.По конвенции, для поля в классе
birthDate
ожидается значение в кортежеbirth_date
В результате сгенерируется метод:
build/generated/source/kapt/PersonRepositoryImpl.kt
public override fun findPeople(): List<Person> {
val query = "SELECT id, name, birth_date FROM person"
return connection.prepareStatement(query).use {
it.executeQuery().use {
val acc = mutableListOf<Person>()
while (it.next()) {
acc +=
Person(
birthDate = it.getObject("birth_date", LocalDate::class.java),
id = it.getObject("id", java.util.UUID::class.java),
name = it.getString("name"),
)
}
acc
}
}
}
Как выполнить этот запрос?
В конфигурации (build.gradle.kts
) мы указывали, что нужно сгенерировать класс my.pkg.DB
. Это главный объект, через который осуществляется доступ ко всем сгенерированным репозиториям. Для его создания нужен DataSource
. Все объявленные нами репозитории доступны внутри транзакции:
main.kt
import my.pkg.DB
import org.sqlite.SQLiteDataSource
fun main() {
val datasource = SQLiteDataSource().apply {
url = "jdbc:sqlite:path/to/my/test.db"
}
val db = DB(datasource)
val people: List<Person> = db.transaction {
personRepository.findPeople()
}
println(people)
}
Запрос с параметрами
@Query("SELECT id, name, birth_date FROM person WHERE name = :firstName")
fun findPeopleBy(firstName: String): List<Person>
Параметры метода могут быть использованы в запросе. Перед именем параметра должно быть двоеточие.
сгенерированный метод
public override fun findPeopleBy(firstName: String): List<Person> {
val query = "SELECT id, name, birth_date FROM person WHERE name = ?"
return connection.prepareStatement(query).use {
it.setString(1, firstName)
it.executeQuery().use {
val acc = mutableListOf<Person>()
while (it.next()) {
acc +=
Person(
birthDate = LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
}
acc
}
}
}
Возвращаемые типы
В зависимости от возвращаемого типа Kotlite
генерирует различное поведение.
Список (List)
Cамый обычный тип. Полностью соответствует тому, что возвращает база от 0 до N элементов. Другие коллекции не поддерживаются.
Сущность (Entity)
На первый взгляд ничего особенного, но есть несколько нюансов:
что если запрос не вернет ни одного значения
что если запрос вернет больше одного значения
В обоих случаях сгенерированный код выбросит исключение. Для второго случая предусмотрена небольшая оптимизация в виде добавления LIMIT 2
.
@Query("SELECT id, name, birth_date FROM person WHERE name = :name")
fun findPersonBy(name: String): Person
Сгенерированный код
public override fun findPersonBy(name: String): Person {
val query = """
|SELECT id, name, birth_date FROM person WHERE name = ?
|LIMIT 2
""".trimMargin()
return connection.prepareStatement(query).use {
it.setString(1, name)
it.executeQuery().use {
if (it.next()) {
val result =
Person(
birthDate = LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
if (it.next()) {
throw IllegalStateException("Query has returned more than one element")
}
result
} else {
throw NoSuchElementException()
}
}
}
}
Для выбора первого значения можно пометить метод аннотацией kotlite.annotations.First
Скаляр
Возвращаемым типом может быть не только сущность, но и любое скалярное ("примитивное") значение. Например: Int
, String
, UUID
LocalDate
и т.п.
@Query("SELECT name FROM person WHERE id = :id")
fun findPersonNameBy(id: UUID): String
Если запрос не вернул ни одного значения, или если вернул больше одного, то так-же как и для сущности будет выброшено исключение.
Сгенерированный метод
public override fun findPersonNameBy(id: UUID): String {
val query = """
|SELECT name FROM person WHERE id = ?
|LIMIT 2
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, id)
it.executeQuery().use {
if (it.next()) {
val result =
it.getString(1)
if (it.next()) {
throw IllegalStateException("Query has returned more than one element")
}
result
} else {
throw NoSuchElementException()
}
}
}
}
Для выбора первого значения можно пометить метод аннотацией kotlite.annotations.First
Nullable значения
Скаляр или сущность могут быть объявлены как Nullable
. В таком случае вернется null
если запрос не вернул ни одной записи.
@Query("SELECT name FROM person WHERE id = :id")
fun findPersonNameBy(id: UUID): String?
Сгенерированный метод
public override fun findPersonNameBy(id: UUID): String? {
val query = """
|SELECT name FROM person WHERE id = ?
|LIMIT 2
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, id)
it.executeQuery().use {
if (it.next()) {
val result =
it.getString(1)
if (it.next()) {
throw IllegalStateException("Query has returned more than one element")
}
result
} else {
null
}
}
}
}
Постраничный вывод (Pagination)
Pageable
определяет сколько элементов размещается на странице, и какую страницу нужно выбрать
import kotlite.aux.page.Page
import kotlite.aux.page.Pageable
@SqliteRepository
interface PersonRepository : Repository<Person> {
@Query("SELECT name FROM person")
fun selectAll(pageable: Pageable): Page<String>
}
Сгенерированный метод
public override fun selectAll(pageable: Pageable): Page<String> {
val query = """
|SELECT name FROM person
|LIMIT ? OFFSET ?
""".trimMargin()
return connection.prepareStatement(query).use {
it.setInt(1, pageable.pageSize)
it.setInt(2, pageable.offset)
it.executeQuery().use {
val acc = mutableListOf<String>()
while (it.next()) {
acc +=
it.getString(1)
}
Page(pageable, acc)
}
}
}
Генерация SQL
Все что мы рассмотрели до этого – была в основном генерация JDBC кода. SQL запросы нужно было писать разработчику самостоятельно. Но во многих случаях запросы выглядят тривиально, и могут быть сгенерированы автоматически.
Для этого нужно дать библиотеке немного информации о том, с какой сущностью мы работаем. Делается это через переменную типа, интерфейса kotlite.aux.Repository
import kotlite.annotations.SqliteRepository
import kotlite.aux.Repository
@SqliteRepository
interface PersonRepository : Repository<Person>
Теперь библиотека знает достаточно о нашей сущности, чтобы можно было сгенерировать SQL автоматически.
Известно название таблицы. По конвенции это имя клaсса, сконвертированное из UpperCamelCase
в snake_case
. Название таблицы может быть явно указано в аннотацииkotlite.annotations.Table
.
Также известно количество, названия и типы колонок таблицы. Названия колонок конвертируются из camelCase
в snake_case
Альтернативно, название может быть указано в аннотации kotlite.annotations.Column
Что это нам дает?
Сохранение и обновление
Для любого метода, имя которого начинается на save
(либо который помечен аннотацией kotlite.annotations.Save
) будет сгенерирован INSERT
. Такой метод должен принимать в качестве параметро либо саму сущность, либо список сущностей. Возвращаемый тип должен быть Unit
fun save(person: Person)
Сгенерированный метод
public override fun save(person: Person): Unit {
val query = """
|INSERT INTO person
|("birth_date", "id", "name")
|VALUES (?, ?, ?)
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.birthDate)
it.setObject(2, person.id)
it.setString(3, person.name)
it.executeUpdate()
}
}
Если сущность имеет первичный ключ (как минимум одно из полей помечено аннотацией kotlite.annotations.ID
) будет сгенерирован INSERT/UPDATE
Сгенерированный метод
public override fun save(person: Person): Unit {
val query = """
|INSERT INTO person
|("birth_date", "id", "name")
|VALUES (?, ?, ?)
|ON CONFLICT (id) DO
|UPDATE SET "birth_date" = EXCLUDED."birth_date", "id" = EXCLUDED."id", "name" = EXCLUDED."name"
|""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.birthDate)
it.setObject(2, person.id)
it.setString(3, person.name)
it.executeUpdate()
}
}
Это поведение можно переопределить аннотацией:
import kotlite.annotations.OnConflictFail
@OnConflictFail
fun save(person: Person)
Оптимистическая блокировка
Если числовое поле класса помечено аннотацией kotlite.annotations.Version
для такой сущности запросы обновления и удаления будут содержать проверку текущей версии
Сгенерированные методы
public override fun save(person: Person): Unit {
val query = """
|INSERT INTO person
|("birth_date", "id", "name", "version")
|VALUES (?, ?, ?, ? + 1)
|ON CONFLICT (id) DO
|UPDATE SET "birth_date" = EXCLUDED."birth_date", "id" = EXCLUDED."id", "name" = EXCLUDED."name", "version" = EXCLUDED."version"
|WHERE person.version = EXCLUDED.version - 1
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.birthDate)
it.setObject(2, person.id)
it.setString(3, person.name)
it.setInt(4, person.version)
val rows = it.executeUpdate()
if (rows != 1) {
throw OptimisticLockFailException()
}
}
}
public override fun delete(person: Person): Unit {
val query = """
|DELETE
|FROM person
|WHERE "id" = ? AND "version" = ?
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.id)
it.setInt(2, person.version)
val rows = it.executeUpdate()
if (rows != 1) {
throw OptimisticLockFailException()
}
}
}
Сге
Удаление
Для любого метода, имя которого начинается на delete (или который помечен аннотацией kotlite.annotations.Delete
) будет сгенерирован DELETE
fun deleteAll()
Сгенерированный метод
public override fun deleteAll(): Unit {
val query = """
|DELETE
|FROM person
""".trimMargin()
return connection.prepareStatement(query).use {
it.executeUpdate()
}
}
Такой метод может принимать сущность в качестве параметра:
fun delete(person: Person)
Удаление будет происходить по всем полям сущности
Сгенерированный метод
public override fun delete(person: Person): Unit {
val query = """
|DELETE
|FROM person
|WHERE "birth_date" = ? AND "id" = ? AND "name" = ?
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.birthDate)
it.setObject(2, person.id)
it.setString(3, person.name)
it.executeUpdate()
}
}
Если сущность имеет первичный ключ (хотя бы одно поле помечено kotlite.annotations.Id
) – удаление будет по первичному ключу:
Сгенерированный метод
public override fun delete(person: Person): Unit {
val query = """
|DELETE
|FROM person
|WHERE "id" = ?
""".trimMargin()
return connection.prepareStatement(query).use {
it.setObject(1, person.id)
it.executeUpdate()
}
}
Кроме этого метод удаления может так-же принимать и другие параметры, см. разделы "Метод с параметрами" и "Сложные условия" ниже.
Метод без параметров
Любой метод, объявленный в репозитории, считается запросом типа SELECT
(кроме методов, названия которых начинаются со слов save
и delete
).
fun selectAll(): List<Person>
Сгенерированный метод
public override fun selectAll(): List<Person> {
val query = """
|SELECT "birth_date", "id", "name"
|FROM person
""".trimMargin()
return connection.prepareStatement(query).use {
it.executeQuery().use {
val acc = mutableListOf<Person>()
while (it.next()) {
acc +=
Person(
birthDate = LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
}
acc
}
}
}
Функции fun selectAll(): List<Person>
и fun blaBlaBla(): List<Person>
ничем не отличаются друг от друга и для них будет сгенерирован абсолютно одинаковый код.
Метод с параметрами
Все параметры метода должны совпадать по названию с полями класса. Они будут использованы как условия равенства во WHERE
и объединены через AND
.
fun selectBy(name: String, birthDate: LocalDate): Person?
Сгенерированный метод
public override fun selectBy(name: String, birthDate: LocalDate): Person? {
val query = """
|SELECT "birth_date", "id", "name"
|FROM person
|WHERE "name" = ? AND "birth_date" = ?
|LIMIT 2
""".trimMargin()
return connection.prepareStatement(query).use {
it.setString(1, name)
it.setObject(2, birthDate)
it.executeQuery().use {
if (it.next()) {
val result =
Person(
birthDate = java.time.LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
if (it.next()) {
throw IllegalStateException("Query has returned more than one element")
}
result
} else {
null
}
}
}
}
Сложные условия
Если вместо стандартного равенства нужно использовать >
, <=
, !=
и т.д., или условия должны быть объединены с помощьюOR
с расстановкой скобок, для этого подойдет аннотация kotlite.annotations.Where
:
@Where("name = :name OR birth_date < :birthDate")
fun selectBy(name: String, birthDate: LocalDate): Person?
Её содержимое будет подставлено в запрос почти без изменений.
Сгенерированный метод
public override fun selectBy(name: String, birthDate: LocalDate): Person? {
val query = """
|SELECT "birth_date", "id", "name"
|FROM person
|WHERE name = ? OR birth_date < ?
|LIMIT 2
""".trimMargin()
return connection.prepareStatement(query).use {
it.setString(1, name)
it.setObject(2, birthDate)
it.executeQuery().use {
if (it.next()) {
val result =
Person(
birthDate = java.time.LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
if (it.next()) {
throw IllegalStateException("Query has returned more than one element")
}
result
} else {
null
}
}
}
}
Сортировка
Часто вместе с постраничным выводом необходимо задать порядок:
@OrderBy("name DESC, birth_date")
fun selectAll(): List<Person>
Сгенерированный метод
public override fun selectAll(): List<Person> {
val query = """
|SELECT "birth_date", "id", "name"
|FROM person
|ORDER BY name DESC, birth_date
""".trimMargin()
return connection.prepareStatement(query).use {
it.executeQuery().use {
val acc = mutableListOf<Person>()
while (it.next()) {
acc +=
Person(
birthDate = LocalDate.parse(it.getString("birth_date")),
id = UUID.fromString(it.getString("id")),
name = it.getString("name"),
)
}
acc
}
}
}
Вложенные объекты
Вложенные объекты не могут быть представлены как связь один-к-одному
. Поля вложенных объектов должны быть представлены колонками в этой же таблице. Т.е. быть @Embeddable
в терминах JPA.
data class Person(
val name: Name,
)
data class Name(
val firstName: String,
val lastName: String,
)
CREATE TABLE person(
first_name text,
last_name text
)
Альтернативно вложенные объекты могут быть сериализованы в JSON. Предмет для добавления в ближайшие версии.
Вложенные коллекции
Вложенные коллекции не могут быть представлены как связь один-ко-многим
. Вместо этого они автоматически сериализуются в JSON.
data class Person(
val habits: List<String>
)
@SqliteRepository
interface PersonRepository: Repository<Person> {
fun save(person: Person)
fun select(): List<Person>
}
Сгенерированные методы
public override fun select(): List<Person> {
val query = """
|SELECT "habits"
|FROM person
""".trimMargin()
return connection.prepareStatement(query).use {
it.executeQuery().use {
val acc = mutableListOf<Person>()
while (it.next()) {
acc +=
Person(
habits = Json.decodeFromString(it.getString("habits")),
)
}
acc
}
}
}
public override fun save(person: Person): Unit {
val query = """
|INSERT INTO person
|("habits")
|VALUES (?)
""".trimMargin()
return connection.prepareStatement(query).use {
it.setString(1, Json.encodeToString(person.habits))
it.executeUpdate()
}
}
Особенности (сравнительно с JPA/Hibernate)
Из-за использования SQL, рефакторинг (например, переименование поля сущности) может потребовать изменения тех запросов, которые были написаны вручную.
Поскольку во главу угла поставлена простота, нет возможности создавать связи `один-к-одному`, `один-ко-многим` (и нет N+1 проблемы).
Нет ленивых загрузок (и нет `SessionClosedException`).
Нет встроенного механизма конвертеров типов (не переусложнен API, библиотека решает только одну задачу).
Нет возможности сохранения иерархий наследования (в основном из-за личной неприязни автора к наследованию. Возможно будет добавлено в будущем).
Не питает иллюзий относительно легкой миграции на другую базу данных.
На этом наши полномочия всё
Спасибо за уделенное внимание.
Dreablin
С Postgresql дело не имел, а для SQLite — чем оно отличается от Room?
На беглый взгляд даже синтаксис совпадает…
maxzh83
Напомнило реактивный репозиторий ReactiveCrudRepository ( spring.io/guides/gs/accessing-data-r2dbc )
А где управление транзакциями?
MaxFarsikov Автор
можно глянуть здесь: mfarsikov.github.io/kotlite/#transactions
MaxFarsikov Автор
Да, очень похоже. Принципы те же (kapt, генерация кода, sql-first).
Не слышал о нем, возможно он специфичен для Android.
На первый взгляд Kotlite умеет чуть больше в генерации SQL (но могу ошибаться). В примерах выше, для метода:
`fun selectBy(name: String, birthDate: LocalDate): Person?`
SQL будет сгенерирован автоматически.