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
  1. Добавить плагин для обработки аннотаций и генерации кода (`kapt`).

  2. Добавить зависимость на core-часть библиотеки. Она содержит необходимые аннотации, и некоторый обвязочный код.

  3. Сериализация в/из JSON используется для вложенных коллекций.

  4. Непосредственно драйвер Sqlite базы.

  5. Плагин создаст kapt конфигурацию, в которую нужно включить зависимость на `kapt`-часть библиотеки. Именно она занимается генерацией SQL запросов и кода JDBC.

  6. Необходимо указать полное имя класса (включая пакет), через который мы будем работать с базой данных (этот класс также  будет сгенерирован).

Репозиторий

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, библиотека решает только одну задачу).

  • Нет возможности сохранения иерархий наследования (в основном из-за личной неприязни автора к наследованию. Возможно будет добавлено в будущем).

  • Не питает иллюзий относительно легкой миграции на другую базу данных.

На этом наши полномочия всё

Спасибо за уделенное внимание.

Sqlite

Posgresql