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
-
удалить по имени
будет достаточно создать интерфейс:
@SqliteRepositoryinterface 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@SqliteRepositoryinterface PersonRepository
От такого репозитория пользы немного, но уже для него Kotlite может сгенерировать имплементацию.
Команда ./gradlew kaptKotlin
сгенерирует:
@Generatedinternal class PersonRepositoryImpl( private val connection: Connection) : PersonRepository
Первый запрос
import kotlite.annotations.Queryimport kotlite.annotations.SqliteRepository@SqliteRepositoryinterface 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.DBimport org.sqlite.SQLiteDataSourcefun 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.Pageimport kotlite.aux.page.Pageable@SqliteRepositoryinterface 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.SqliteRepositoryimport kotlite.aux.Repository@SqliteRepositoryinterface 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@OnConflictFailfun 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>)@SqliteRepositoryinterface 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, библиотека решает только одну задачу).
-
Нет возможности сохранения иерархий наследования (в основном из-за личной неприязни автора к наследованию. Возможно будет добавлено в будущем).
-
Не питает иллюзий относительно легкой миграции на другую базу данных.
На этом наши полномочия всё
Спасибо за уделенное внимание.