Общее
Базовые принципы и механизмы работы модулей баз данных в Kora.
В этом разделе описана общая модель для JDBC и Cassandra: отображения, репозитории, параметры запросов, пакетные запросы, количество затронутых строк и макросы.
Конфигурация подключения, транзакции, поддерживаемые сигнатуры и специфичные для драйвера отображатели описаны в документации для каждой реализации базы данных.
Этот раздел намеренно не описывает специфичные для драйвера детали.
Конфигурацию подключения, транзакции, типы возвращаемых значений, генерируемые базой данных идентификаторы, служебные параметры методов
и точные интерфейсы отображателей смотрите в документации для нужной реализации:
JDBC или Cassandra.
Мы считаем, что лучший способ общения с базой данных SQL — это общение на её родном языке SQL. Другие инструменты часто имеют ограничения на использование специфичных функций конкретной базы данных или сложный программный язык для построения запросов, который требует дополнительного и значительного времени на изучение и освоение, несёт много неочевидности и потенциальных ошибок со стороны разработчика, а также порой обладает низкой производительностью.
Если нужен пошаговый разбор перед справочным описанием, смотрите База данных JDBC и Продвинутая база данных JDBC.
Использование¶
Использование показано на примере модуля JDBC: репозиторий объявляется как интерфейс, помеченный
аннотацией @Repository, и наследует JdbcRepository.
Каждый метод, помеченный @Query, содержит обычный SQL-запрос. Параметры метода связываются по имени с помощью
синтаксиса :parameter, а к полям сущности можно обращаться как :entity.field.
Отображения описываются с помощью общих аннотаций отображений и дополнительно помечаются @EntityJdbc
(@EntityCassandra для Cassandra), чтобы Kora сгенерировала отображатель на этапе компиляции:
@Repository
public interface EntityRepository extends JdbcRepository {
@EntityJdbc
@Table("entities")
record Entity(@Id long id,
String name,
@Nullable String description) {}
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
@Nullable
Entity findById(long id);
@Query("SELECT id, name, description FROM entities") //(2)!
List<Entity> findAll();
@Query("INSERT INTO %{entity#inserts}") //(3)!
UpdateCount insert(Entity entity);
}
@Repository
interface EntityRepository : JdbcRepository {
@EntityJdbc
@Table("entities")
data class Entity(
@field:Id val id: Long,
val name: String,
val description: String?
)
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
fun findById(id: Long): Entity?
@Query("SELECT id, name, description FROM entities") //(2)!
fun findAll(): List<Entity>
@Query("INSERT INTO %{entity#inserts}") //(3)!
fun insert(entity: Entity): UpdateCount
}
SQL остаётся под контролем разработчика: вы можете использовать специфичные для базы данных возможности, тогда как Kora
берёт на себя только безопасное связывание параметров, выполнение запроса и отображение результата.
Репозиторий также можно объявить абстрактным классом, реализующим интерфейс драйвера — тогда методы запросов являются
абстрактными методами этого класса, и видимость метода запроса не обязана быть public.
В Kotlin метод запроса не может быть suspend — генератор репозиториев отвергает такой метод на этапе компиляции.
Точный набор поддерживаемых типов возвращаемых значений специфичен для драйвера и описан на странице каждой реализации.
Связывание параметров: Kora генерирует типизированное связывание аргументов SQL-запроса на этапе компиляции.
Параметры запроса вида :id или :entity.name заменяются в сгенерированном коде на соответствующие вызовы драйвера.
Например, для параметра String name будет сгенерировано что-то вроде statement.setString(1, name), где индекс
соответствует позиции параметра в запросе.
Это даёт и безопасность (защиту от SQL-инъекций), и производительность (всегда используются подготовленные запросы).
Отображение¶
Отображение — это представление данных из базы данных в виде класса с полями.
Отображение — это либо record, который создаётся через свой канонический конструктор,
либо JavaBean — класс с публичным конструктором без аргументов и парой getX() / setX() для каждого
отображаемого поля, который создаётся пустым и затем заполняется через сеттеры.
Таблица¶
Вы можете указать, к какой таблице относится отображение — это понадобится, если вы используете макросы при построении запросов.
Если таблица не указана, макросы используют имя класса в snake_lower_case.
Идентификатор¶
Поскольку все манипуляции с данными выполняются путём преобразования отображения в запрос драйвера, нет необходимости выделять внутри отображения специальный первичный ключ для работы с ним.
Определение того, что именно является первичным ключом, может быть полезно при использовании макросов,
для этого можно использовать аннотацию @Id.
Последовательный¶
Рассмотрим создание идентификатора в виде последовательности чисел на примере Postgres: Kora предлагает использовать механизм базы данных identity column.
Пример таблицы для такого отображения выглядел бы так:
CREATE TABLE IF NOT EXISTS entities
(
id BIGINT GENERATED ALWAYS AS IDENTITY,
name VARCHAR NOT NULL,
PRIMARY KEY (id)
);
Идентификатор создаётся на этапе вставки в базу данных, а получение его в коде приложения предполагается через конструкцию генерируемого идентификатора при вставке либо через специальные конструкции вашей базы данных:
public record Entity(Long id, String name) {}
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = :id")
@Nullable
Entity findById(long id);
@Query("INSERT INTO entities(name) VALUES (:entity.name) RETURNING id")
long insert(Entity entity);
}
data class Entity(val id: Long, val name: String)
@Repository
interface EntityRepository : JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = :id")
fun findById(id: Long): Entity?
@Query("INSERT INTO entities(name) VALUES (:entity.name) RETURNING id")
fun insert(entity: Entity): Long
}
Вместо специфичного для драйвера RETURNING первичный ключ, сгенерированный базой данных при вставке, можно вернуть,
пометив аннотацией @Id сам метод репозитория (аннотация применима и к полю отображения, и к методу).
Генерируемые идентификаторы поддерживает драйвер JDBC, точное поведение
и поддерживаемые сигнатуры возвращаемых значений описаны там:
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
public record Entity(@Id Long id, String name) {}
@Id //(1)!
@Query("INSERT INTO %{entity#inserts -= @id}") //(2)!
long insert(Entity entity);
}
- Помечает метод так, чтобы возвращался идентификатор, сгенерированный базой данных.
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(@field:Id val id: Long, val name: String)
@Id //(1)!
@Query("INSERT INTO %{entity#inserts -= @id}") //(2)!
fun insert(entity: Entity): Long
}
- Помечает метод так, чтобы возвращался идентификатор, сгенерированный базой данных.
- Разворачивается в запрос:
Случайный¶
Для создания случайного идентификатора предлагается использовать стандартный UUID из Java:
Пример таблицы для такого отображения выглядел бы так:
Идентификатор создаётся на этапе создания объекта в пользовательском коде приложения:
public record Entity(UUID id,
String name) {}
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = :id")
@Nullable
Entity findById(UUID id);
@Query("INSERT INTO entities(id, name) VALUES (:entity.id, :entity.name)")
void insert(Entity entity);
}
Составной¶
Когда требуется составной ключ, предполагается использовать аннотацию @Embedded для создания встроенных полей.
Именование¶
По умолчанию имена полей отображения преобразуются в snake_lower_case при получении результата.
Если требуется настроить соответствие конкретных полей базы данных отображению, можно использовать аннотацию @Column:
Стратегия именования¶
Если требуется применить стратегию именования ко всему отображению, предлагается создать реализацию NameConverter и указать её в аннотации @NamingStrategy.
Реализация NameConverter обязана иметь доступный конструктор без параметров.
Либо используйте готовые стратегии из Kora (io.koraframework.common.naming):
NoopNameConverter— стратегия использует имя поля как есть.SnakeCaseNameConverter— стратегия используетsnake_lower_case.SnakeCaseUpperNameConverter— стратегия использует SNAKE_UPPER_CASE.PascalCaseNameConverter— стратегия использует PascalCase.CamelCaseNameConverter— стратегия использует camelCase.
Аннотация @Column на поле всегда имеет приоритет над стратегией именования отображения.
Обязательные поля¶
По умолчанию все поля, объявленные в отображении, считаются обязательными (NotNull).
По умолчанию все поля, объявленные в отображении и не использующие синтаксис Kotlin Nullability, считаются обязательными (NotNull).
Необязательные поля¶
Если поле отображения необязательное, то есть может отсутствовать,
пометьте его явно аннотацией @Nullable.
- Сама Kora использует JSpecify
org.jspecify.annotations.Nullable, но подойдёт любая аннотация, имя которой заканчивается наNullable, напримерjakarta.annotation.Nullable/org.jetbrains.annotations.Nullableи т.д.
Также можно указать необязательные параметры конструктора, если канонический конструктор Record переопределён:
public record Entity(String id,
String name) {
public Entity(String id,
@Nullable String name) { //(1)!
this.id = id;
this.name = name;
}
}
- Сама Kora использует JSpecify
org.jspecify.annotations.Nullable, но подойдёт любая аннотация, имя которой заканчивается наNullable, напримерjakarta.annotation.Nullable/org.jetbrains.annotations.Nullableи т.д.
Предполагается использование синтаксиса Kotlin Nullability и пометка такого параметра как Nullable:
Встроенные поля¶
Если требуется использовать вложенные поля, то есть преобразовывать поля отображения в отдельные классы, можно использовать аннотацию @Embedded.
Предположим, есть SQL-таблица с составным ключом, который мы хотим выразить отдельным классом:
CREATE TABLE IF NOT EXISTS entities
(
name VARCHAR NOT NULL,
surname VARCHAR NOT NULL,
info VARCHAR NOT NULL,
PRIMARY KEY (name, surname)
)
Тогда отображение будет выглядеть так:
Тогда репозиторий для такого отображения будет выглядеть так:
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("""
SELECT name, surname, info FROM entities
WHERE name = :id.name AND surname = :id.surname;
""")
@Nullable
Entity findById(Entity.UserID id);
@Query("""
INSERT INTO entities(name, surname, info)
VALUES (:entity.id.name, :entity.id.surname, :entity.info)
""")
void insert(Entity entity);
}
@Repository
interface EntityRepository : JdbcRepository {
@Query(
"""
SELECT name, surname, info FROM entities
WHERE name = :id.name AND surname = :id.surname;
"""
)
fun findById(id: Entity.UserID): Entity?
@Query(
"""
INSERT INTO entities(name, surname, info)
VALUES (:entity.id.name, :entity.id.surname, :entity.info)
"""
)
fun insert(entity: Entity)
}
По умолчанию встроенное поле не добавляет префикс к столбцам вложенного класса.
Если у полей есть общий префикс, его можно указать в аннотации @Embedded("user_"):
CREATE TABLE IF NOT EXISTS entities
(
user_name VARCHAR NOT NULL,
user_surname VARCHAR NOT NULL,
info VARCHAR NOT NULL,
PRIMARY KEY (user_name, user_surname)
)
Встроенное поле может быть @Nullable: если все столбцы вложенного класса в строке результата равны NULL —
именно это даёт LEFT JOIN без совпадения — всё поле выставляется в null, а не конструируется.
Встроенное поле также может быть List вложенного отображения: строки с одинаковыми значениями столбцов внешнего
отображения схлопываются в один внешний объект, а собранные из этих строк вложенные отображения становятся его коллекцией.
Так связь «один ко многим» через JOIN отображается в один объект:
Репозиторий¶
Основной инструмент работы с базами данных в Kora — использование паттерна репозиторий при проектировании абстракции доступа к базе данных.
Интерфейс репозитория должен быть помечен аннотацией @Repository.
Запросы для методов репозитория описываются с помощью аннотации @Query.
Реализация репозитория создаётся на этапе компиляции: все методы с @Query выполняют описанный запрос, оптимально собирают аргументы запроса и обрабатывают результат.
Предполагается, что SQL-запросы пишет разработчик, поскольку это повышает его понимание плана запроса,
даёт больше информации и контекста о том, что запрос делает и как он будет работать.
Чтобы не выписывать все поля и столбцы модели вручную, можно использовать макросы.
Репозиторий должен наследовать одну из реализаций, в примерах ниже рассматривается реализация JDBC:
@Repository //(1)!
public interface EntityRepository extends JdbcRepository {
public record Entity(String id, String name) { }
//(2)!
@Query("SELECT id, name FROM entities WHERE id = :id")
@Nullable
Entity findById(String id);
}
- Указывает, что интерфейс является репозиторием.
- Указывает, что Kora должна создать реализацию метода, выполняющую
SQL-запрос, указанный в аннотации.
@Repository //(1)!
interface EntityRepository : JdbcRepository {
data class Entity(val id: String, val name: String)
//(2)!
@Query("SELECT id, name FROM entities WHERE id = :id")
fun findById(id: String): Entity?
}
- Указывает, что интерфейс является репозиторием.
- Указывает, что Kora должна создать реализацию метода, выполняющую
SQL-запрос, указанный в аннотации.
Параметры запроса¶
Параметры метода репозитория связываются с именованными параметрами в @Query.
Простой параметр указывается по имени параметра метода: :id, :name, :status.
Если параметр является сущностью или DTO, к его полям можно обращаться через точку: :entity.id, :entity.name, :filter.status.
Если параметр встречается в запросе более одного раза, Kora связывает его с каждым вхождением.
Связывание проверяется на этапе компиляции в обе стороны:
- если параметр метода не используется в запросе и не является служебным параметром конкретного драйвера, компиляция падает с ошибкой
Query parameter is unused; - если в запросе есть плейсхолдер, которому не соответствует ни один параметр метода, компиляция падает с ошибкой
SQL query placeholder has no matching method parameterи списком доступных параметров; - если в запросе указан
:entity.fieldдля отсутствующего поля сущности, компиляция падает с ошибкойSQL query placeholder has no matching entity fieldи списком доступных полей.
Текст, который лишь выглядит как плейсхолдер, игнорируется, поэтому SQL с двоеточиями продолжает компилироваться.
Kora пропускает :name внутри строк в одинарных кавычках, идентификаторов в двойных кавычках и обратных апострофах,
идентификаторов в [квадратных скобках], блоков $$dollar quoted$$ и $tag$dollar quoted$tag$,
однострочных комментариев -- и блочных комментариев /* */, а оператор приведения типа Postgres :: параметром не считается.
Запрос из ресурса¶
Запрос можно хранить в отдельном SQL-файле вместо аннотации. Если значение @Query начинается с classpath:/,
остаток значения трактуется как путь к ресурсу относительно корней исходников и classpath модуля, а содержимое файла
читается на этапе компиляции и используется как запрос.
Файл проходит ровно ту же обработку, что и встроенный запрос: разворачиваются макросы и
связываются с проверкой параметры.
Если ресурс прочитать не удалось, компиляция падает с ошибкой SQL query resource wasn't found.
Отображатели¶
Используйте аннотацию @Mapping, когда значению нужно нестандартное представление в базе данных.
Её можно разместить на поле отображения, параметре метода или методе репозитория:
- на поле отображения — чтобы настроить чтение или запись конкретного столбца;
- на параметре метода — чтобы настроить запись конкретного параметра запроса;
- на методе репозитория — чтобы настроить обработку всего результата запроса или строки результата.
Произвольный отображатель нельзя использовать в любом месте: его тип должен соответствовать месту применения.
Отображатель параметра применяется к параметру запроса, отображатель столбца — к полю отображения, а отображатель результата или строки — к методу репозитория.
Точный набор поддерживаемых интерфейсов зависит от драйвера: например, JDBC использует JdbcRowMapper, JdbcResultSetMapper, JdbcResultColumnMapper и JdbcParameterColumnMapper.
Соответствующие интерфейсы для Cassandra, а также детали их использования описаны в документации этой реализации.
Все отображатели строк драйверов имеют общий маркерный интерфейс RowMapper<T> (io.koraframework.database.common.RowMapper), который является базовым типом для специфичных для драйвера отображателей, таких как JdbcRowMapper и CassandraRowMapper.
Сама аннотация @Mapping находится в основном модуле common (io.koraframework.common.annotation.Mapping), а каждый контракт отображателя реализует её маркер Mapping.MappingFunction.
Если отображатель указан через @Mapping, Kora добавляет его как зависимость сгенерированного репозитория и использует вместо отображателя по умолчанию.
Отображатель с зависимостями в конструкторе обязан быть компонентом графа приложения.
Отображатель без зависимостей компонентом быть не должен: Kora создаёт его сама, а объявление его компонентом
приводит к ошибке сборки графа Multiple components match.
Пакетный запрос¶
Kora поддерживает пакетные запросы с помощью аннотации @Batch.
В отличие от последовательного выполнения SQL-запросов, пакетная обработка позволяет отправить целый набор запросов за один вызов, сокращая число требуемых сетевых обращений и позволяя выполнять часть запросов параллельно на стороне базы данных, что может повысить скорость выполнения.
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("INSERT INTO entities(id, name) VALUES (:entity.id, :entity.name)")
void insert(@Batch List<Entity> entity);
}
Пакетный запрос не может возвращать произвольные значения — такой метод может возвращать void, UpdateCount, int[] или long[],
либо генерируемые базой данных идентификаторы, если метод помечен @Id, для драйвера JDBC.
@Repository
interface EntityRepository : JdbcRepository {
@Query("INSERT INTO entities(id, name) VALUES (:entity.id, :entity.name)")
fun insert(@Batch entity: List<Entity>)
}
Пакетный запрос не может возвращать произвольные значения — такой метод может возвращать Unit, UpdateCount, IntArray или LongArray,
либо генерируемые базой данных идентификаторы, если метод помечен @Id, для драйвера JDBC.
@Batch ставится на параметр типа List, и каждый элемент списка по очереди подставляется в один и тот же запрос.
Любой другой тип коллекции отвергается на этапе компиляции с ошибкой @Batch can be used only with java.util.List<T> parameters.
Все остальные параметры метода, если они есть, являются общими для всех элементов пакета.
Например, в INSERT INTO logs(tenant_id, id, value) VALUES (:tenantId, :entity.id, :entity.value)
параметр tenantId одинаков для каждого элемента, тогда как поля entity берутся из каждого элемента списка.
Метод может объявлять только один параметр с @Batch.
Поддержка генерируемых базой данных идентификаторов в пакетных запросах зависит от конкретного драйвера и описана в соответствующем разделе.
Затронутые строки¶
Kora не обрабатывает содержимое запроса, результат метода всегда выводится из строк, возвращённых базой данных.
Если вы хотите получить в результате количество затронутых строк, используйте специальный тип UpdateCount — запись с единственным компонентом long value().
Для обычного запроса UpdateCount#value() содержит количество строк, возвращённое драйвером для выполненного запроса.
Для пакетного запроса в JDBC значение равно сумме счётчиков по всем элементам пакета и равно -1, если хотя бы для одного
элемента драйвер вернул SUCCESS_NO_INFO вместо количества строк.
Ручной запрос¶
Если по какой-либо причине функциональности запросов в аннотации @Query недостаточно или требуется ручное управление соединением,
можно использовать встроенный в репозиторий исполнитель, чтобы создать метод с полностью ручным управлением.
Каждый интерфейс репозитория драйвера отдаёт свой исполнитель методом executor(): JdbcRepository#executor() возвращает
JdbcExecutor, а CassandraRepository#executor() — CassandraExecutor.
Внутри такого метода можно также использовать другие методы репозитория, и при необходимости они тоже будут выполняться в рамках одной транзакции. Подробнее о транзакциях смотрите в документации для конкретной реализации репозитория.
Репозитории могут объявлять обычные методы с реализациями.
Это полезно, когда более сложную операцию стоит держать рядом с запросами: например, выполнение нескольких методов @Query в одной транзакции,
построение результата из нескольких запросов или хранение последовательности операций с базой данных внутри репозитория вместо переноса её в слой сервиса.
@Repository
public interface EntityRepository extends JdbcRepository {
public record Entity(Long id, String name) {}
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
UpdateCount insert(Entity entity);
@Query("UPDATE entities SET name = :name WHERE id = :id")
UpdateCount updateName(Long id, String name);
default Entity saveAndRename(Entity entity, String name) {
return executor().inTx(() -> { //(1)!
insert(entity);
updateName(entity.id(), name);
return new Entity(entity.id(), name);
});
}
}
JdbcExecutor#inTxвыполняет callback в транзакции, переиспользуя текущую, если транзакция уже открыта.
@Repository
interface EntityRepository : JdbcRepository {
data class Entity(val id: Long, val name: String)
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
fun insert(entity: Entity): UpdateCount
@Query("UPDATE entities SET name = :name WHERE id = :id")
fun updateName(id: Long, name: String): UpdateCount
fun saveAndRename(entity: Entity, name: String): Entity {
return executor().inTx(JdbcExecutor.SqlSupplier { //(1)!
insert(entity)
updateName(entity.id, name)
Entity(entity.id, name)
})
}
}
- У
inTxмного перегрузок, поэтому Kotlin не может вывести, какой функциональный интерфейс реализует обычная лямбда — SAM-конструкторJdbcExecutor.SqlSupplier(илиJdbcExecutor.SqlRunnableдля метода без результата) нужно указывать явно.
Когда вы строите SQL вручную через исполнитель драйвера, а не с помощью метода @Query,
запрос всё равно проходит через телеметрию Kora. Выполняемый запрос описывается общим
QueryContext(queryId, sql, operation): queryId — это стабильный идентификатор запроса, передаваемый в телеметрию
(удобно использовать имя вида Repository.method), sql — итоговый текст запроса, а operation по умолчанию равно db_query.
Точный метод исполнителя и его сигнатура специфичны для драйвера — рабочий пример смотрите в JDBC.
Несколько баз данных¶
Иногда в рамках одного приложения нужно обращаться к разным базам данных в разных репозиториях, это можно решить следующим образом. Ниже приведён пример для базы данных JDBC, но принцип тот же и для Cassandra.
Соединение с базой данных предоставляется модулем-фабрикой: JdbcDatabaseModule объявляет new JdbcDatabaseFactoryModule("jdbc")
как @FactoryModule, и каждый компонент, созданный таким модулем-фабрикой, наследует тег метода-фабрики.
Чтобы добавить вторую базу данных, объявите ещё один метод @FactoryModule со своим путём конфигурации и своим тегом:
@KoraApp
public interface Application extends JdbcDatabaseModule {
final class OtherDatabase { }
@Tag(OtherDatabase.class) //(1)!
@FactoryModule //(2)!
default JdbcDatabaseFactoryModule otherJdbcDatabase() {
return new JdbcDatabaseFactoryModule("otherDb"); //(3)!
}
}
- Тег, который получают все компоненты, созданные этим модулем-фабрикой.
- Сообщает Kora, что возвращаемый объект сам является модулем, а его методы — фабриками компонентов.
- Секция конфигурации второй базы данных.
@KoraApp
interface Application : JdbcDatabaseModule {
class OtherDatabase
@Tag(OtherDatabase::class) //(1)!
@FactoryModule //(2)!
fun otherJdbcDatabase(): JdbcDatabaseFactoryModule {
return JdbcDatabaseFactoryModule("otherDb") //(3)!
}
}
- Тег, который получают все компоненты, созданные этим модулем-фабрикой.
- Сообщает Kora, что возвращаемый объект сам является модулем, а его методы — фабриками компонентов.
- Секция конфигурации второй базы данных.
Каждое соединение читает свою секцию конфигурации:
А репозитории, которые будут использовать эту базу данных, теперь обязаны указывать тег этого соединения:
Репозиториям с основным соединением к базе данных тег не требуется.
Макросы¶
Самой утомительной частью написания SQL-запросов может быть перечисление и поддержание в актуальном состоянии столбцов и полей отображения.
Чтобы решить эту проблему, используйте специальные макросы внутри SQL-запроса в аннотации @Query.
Эти конструкции оперируют целевым отображением, разворачивают его в конкретные SQL-конструкции и упрощают расширение SQL-запросов.
Макрос — это помощник для написания SQL-запросов, который разворачивается в конструкции, которые пользователь мог бы написать вручную.
Синтаксис макросов выглядит следующим образом: %{return#selects}.
- Макрос ограничен синтаксической конструкцией
%{и} - Сначала указывается цель макроса — это может быть имя любого аргумента метода, возвращаемое значение через ключевое слово
return, имя параметра типа репозитория или метода, либо вложенное поле любой из этих целей, записанное через точку:return.user - Затем символ
#используется для разделения цели макроса и команды макроса - После этого указывается команда макроса, которая сообщает, в какую SQL-конструкцию развернуть отображение
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
public record Entity(@Id Long id,
@Column("entity_name") String name,
String code) {}
@Query("SELECT %{return#selects} FROM %{return#table}") //(1)!
List<Entity> findAll();
}
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(@field:Id val id: Long,
@field:Column("entity_name") val name: String,
val code: String)
@Query("SELECT %{return#selects} FROM %{return#table}") //(1)!
fun findAll(): List<Entity>
}
- Разворачивается в запрос:
Цель return разворачивает привычные контейнеры результата, поэтому List<Entity>, Optional<Entity> и просто Entity
приводят к одному и тому же отображению.
Команды¶
Доступные команды макросов:
table— разворачивается в значение отображения из аннотации@Table, а при её отсутствии переводит имя отображения вsnake_lower_caseselects— создаёт конструкцию перечисления столбцов отображения для запросаSELECTcolumns— создаёт голое перечисление столбцов отображения, без квалификации таблицей и без псевдонимовvalues— создаёт перечисление параметров запроса, соответствующих полям отображенияinserts— создаёт конструкцию из таблицы, перечисления столбцов и соответствующих полей отображения для запросаINSERTupdates— создаёт конструкцию перечисления столбцов и соответствующих полей отображения для запросаUPDATE, поле идентификатора всегда исключаетсяwhere— создаёт конструкцию перечисления столбцов со значением из отображения для частиWHEREзапроса
columns и values — это две половины inserts, они полезны, когда INSERT требуется написать вручную:
Перечисление полей¶
Макросы поддерживают дополнительный синтаксис перечисления определённых полей в команде,
если вдруг требуется сделать частичное обновление или частичную выборку данных.
Для этого после команды используется специальная конструкция: %{return#updates=name}.
Пробелы можно ставить только между полями в перечислении или вокруг специального символа перечисления.
Доступны специальные символы перечисления:
=— в разворачивании команды участвуют только те поля отображения, имена которых указаны после символа-=— в разворачивании команды участвуют все поля отображения, кроме указанных после символа
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
public record Entity(@Id Long id,
@Column("entity_name") String name,
String code) {}
@Query("INSERT INTO %{entity#inserts=name,code}") //(1)!
UpdateCount insert(Entity entity);
}
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(@field:Id val id: Long,
@field:Column("entity_name") val name: String,
val code: String)
@Query("INSERT INTO %{entity#inserts=name,code}") //(1)!
fun insert(entity: Entity): UpdateCount
}
- Разворачивается в запрос:
Идентификатор¶
При перечислении полей в макросе можно использовать специальное ключевое слово @id,
чтобы сразу сослаться на идентификатор отображения, помеченный аннотацией @Id.
Это особенно полезно, когда идентификатор является составным ключом, чтобы перечислить сразу все столбцы.
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
public record Entity(@Id Long id,
@Column("entity_name") String name,
String code) {}
@Query("INSERT INTO %{entity#inserts-=@id}") //(1)!
UpdateCount insert(Entity entity);
}
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(@field:Id val id: Long,
@field:Column("entity_name") val name: String,
val code: String)
@Query("INSERT INTO %{entity#inserts-=@id}") //(1)!
fun insert(entity: Entity): UpdateCount
}
- Разворачивается в запрос:
Псевдоним таблицы¶
Команда table принимает псевдоним таблицы через синтаксис table as <псевдоним>.
Как только цели назначен псевдоним, все остальные макросы над этой же целью квалифицируют свои столбцы им:
selects выдаёт псевдоним.столбец и, если отображение переименовывает столбец, добавляет AS с итоговым именем,
а where выдаёт псевдоним.столбец = :путь.
Именно это делает макросы применимыми в JOIN, где вложенная цель и встроенное поле с префиксом
отображают соединяемые таблицы в одно отображение:
@Repository
public interface UserOrderRepository extends JdbcRepository {
@Table("users")
record User(@Id String id, String name) {}
@Table("orders")
record Order(@Id String id, @Column("user_id") String userId, String number) {}
record UserOrderView(@Embedded("u_") User user, @Embedded("o_") Order order) {}
@Query("""
SELECT %{return#selects}
FROM %{return.user#table as u} JOIN %{return.order#table as o} ON o.user_id = u.id
WHERE u.id = :id
""") //(1)!
@Nullable
UserOrderView find(String id);
}
- Разворачивается в запрос:
@Repository
interface UserOrderRepository : JdbcRepository {
@Table("users")
data class User(@field:Id val id: String, val name: String)
@Table("orders")
data class Order(@field:Id val id: String, @field:Column("user_id") val userId: String, val number: String)
data class UserOrderView(@field:Embedded("u_") val user: User, @field:Embedded("o_") val order: Order)
@Query(
"""
SELECT %{return#selects}
FROM %{return.user#table as u} JOIN %{return.order#table as o} ON o.user_id = u.id
WHERE u.id = :id
"""
) //(1)!
fun find(id: String): UserOrderView?
}
- Разворачивается в запрос:
Пример репозитория¶
Пример полноценного репозитория со всеми базовыми методами работы с отображением для Postgres SQL:
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
record Entity(@Id String id,
@Column("value1") int field1,
String value2,
@Nullable String value3) {}
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
@Nullable
Entity findById(String id);
@Query("SELECT %{return#selects} FROM %{return#table}") //(2)!
List<Entity> findAll();
@Query("INSERT INTO %{entity#inserts}") //(3)!
UpdateCount insert(@Batch List<Entity> entity);
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}") //(4)!
UpdateCount update(@Batch List<Entity> entity);
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (id) DO UPDATE SET %{entity#updates}") //(5)!
UpdateCount upsert(@Batch List<Entity> entity);
@Query("DELETE FROM entities WHERE id = :id")
UpdateCount deleteById(String id);
@Query("DELETE FROM entities")
UpdateCount deleteAll();
}
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(
@field:Id val id: String,
@field:Column("value1") val field1: Int,
val value2: String,
val value3: String?
)
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
fun findById(id: String): Entity?
@Query("SELECT %{return#selects} FROM %{return#table}") //(2)!
fun findAll(): List<Entity>
@Query("INSERT INTO %{entity#inserts}") //(3)!
fun insert(@Batch entity: List<Entity>): UpdateCount
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}") //(4)!
fun update(@Batch entity: List<Entity>): UpdateCount
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (id) DO UPDATE SET %{entity#updates}") //(5)!
fun upsert(@Batch entity: List<Entity>): UpdateCount
@Query("DELETE FROM entities WHERE id = :id")
fun deleteById(id: String): UpdateCount
@Query("DELETE FROM entities")
fun deleteAll(): UpdateCount
}
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
Пример с составным идентификатором¶
Пример репозитория с составным идентификатором и базовыми методами работы с сущностью:
он почти идентичен предыдущему, за исключением условий WHERE для поиска и удаления, для Postgres SQL:
@Repository
public interface EntityRepository extends JdbcRepository {
@Table("entities")
record Entity(@Id @Embedded EntityId id,
@Column("value1") int field1,
String value2,
@Nullable String value3) {
public record EntityId(String code, String type) { }
}
@Query("SELECT %{return#selects} FROM %{return#table} WHERE %{id#where}") //(1)!
@Nullable
Entity findById(EntityId id);
@Query("SELECT %{return#selects} FROM %{return#table}") //(2)!
List<Entity> findAll();
@Query("INSERT INTO %{entity#inserts}") //(3)!
UpdateCount insert(@Batch List<Entity> entity);
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}") //(4)!
UpdateCount update(@Batch List<Entity> entity);
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (code, type) DO UPDATE SET %{entity#updates}") //(5)!
UpdateCount upsert(@Batch List<Entity> entity);
@Query("DELETE FROM entities WHERE %{id#where}") //(6)!
UpdateCount deleteById(EntityId id);
@Query("DELETE FROM entities")
UpdateCount deleteAll();
}
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
@Repository
interface EntityRepository : JdbcRepository {
@Table("entities")
data class Entity(
@field:Id @field:Embedded val id: EntityId,
@field:Column("value1") val field1: Int,
val value2: String,
val value3: String?
) {
data class EntityId(val code: String, val type: String)
}
@Query("SELECT %{return#selects} FROM %{return#table} WHERE %{id#where}") //(1)!
fun findById(id: EntityId): Entity?
@Query("SELECT %{return#selects} FROM %{return#table}") //(2)!
fun findAll(): List<Entity>
@Query("INSERT INTO %{entity#inserts}") //(3)!
fun insert(@Batch entity: List<Entity>): UpdateCount
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}") //(4)!
fun update(@Batch entity: List<Entity>): UpdateCount
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (code, type) DO UPDATE SET %{entity#updates}") //(5)!
fun upsert(@Batch entity: List<Entity>): UpdateCount
@Query("DELETE FROM entities WHERE %{id#where}") //(6)!
fun deleteById(id: EntityId): UpdateCount
@Query("DELETE FROM entities")
fun deleteAll(): UpdateCount
}
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
- Разворачивается в запрос:
Пример с наследованием¶
Можно также создать абстрактный CRUD-репозиторий и затем использовать его через наследование для Postgres SQL. Макросы разрешают параметры типа родительского репозитория по типовым аргументам конкретного репозитория, поэтому обобщённого родителя достаточно написать один раз:
public interface PostgresJdbcCrudRepository<K, V> extends JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table}")
List<V> findAll();
@Query("INSERT INTO %{entity#inserts}")
UpdateCount insert(V entity);
@Query("INSERT INTO %{entity#inserts}")
UpdateCount insert(@Batch List<V> entity);
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}")
UpdateCount update(V entity);
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}")
UpdateCount update(@Batch List<V> entity);
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (%{entity#columns = @id}) DO UPDATE SET %{entity#updates}")
UpdateCount upsert(V entity);
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (%{entity#columns = @id}) DO UPDATE SET %{entity#updates}")
UpdateCount upsert(@Batch List<V> entity);
@Query("DELETE FROM %{entity#table} WHERE %{entity#where = @id}")
UpdateCount delete(V entity);
@Query("DELETE FROM %{entity#table} WHERE %{entity#where = @id}")
UpdateCount delete(@Batch List<V> entity);
}
@Repository
public interface EntityRepository extends PostgresJdbcCrudRepository<String, Entity> {
@Table("entities")
record Entity(@Id String id,
@Column("value1") int field1,
String value2,
@Nullable String value3) {
}
@Query("DELETE FROM entities WHERE id = :id")
UpdateCount deleteById(String id);
@Query("DELETE FROM entities")
UpdateCount deleteAll();
}
interface PostgresJdbcCrudRepository<K, V> : JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table}")
fun findAll(): List<V>
@Query("INSERT INTO %{entity#inserts}")
fun insert(entity: V): UpdateCount
@Query("INSERT INTO %{entity#inserts}")
fun insert(@Batch entity: List<V>): UpdateCount
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}")
fun update(entity: V): UpdateCount
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}")
fun update(@Batch entity: List<V>): UpdateCount
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (%{entity#columns = @id}) DO UPDATE SET %{entity#updates}")
fun upsert(entity: V): UpdateCount
@Query("INSERT INTO %{entity#inserts} ON CONFLICT (%{entity#columns = @id}) DO UPDATE SET %{entity#updates}")
fun upsert(@Batch entity: List<V>): UpdateCount
@Query("DELETE FROM %{entity#table} WHERE %{entity#where = @id}")
fun delete(entity: V): UpdateCount
@Query("DELETE FROM %{entity#table} WHERE %{entity#where = @id}")
fun delete(@Batch entity: List<V>): UpdateCount
}
@Repository
interface EntityRepository : PostgresJdbcCrudRepository<String, Entity> {
@Table("entities")
data class Entity(
@field:Id val id: String,
@field:Column("value1") val field1: Int,
val value2: String,
val value3: String?
)
@Query("DELETE FROM entities WHERE id = :id")
fun deleteById(id: String): UpdateCount
@Query("DELETE FROM entities")
fun deleteAll(): UpdateCount
}
Телеметрия¶
Все драйверы баз данных используют общий контракт телеметрии для логирования, метрик и трассировки запросов.
Конкретные параметры конфигурации (секция telemetry { logging / metrics / tracing }) описаны в документации
для каждого драйвера, например JDBC; этот раздел документирует только общие точки расширения,
которые находятся в io.koraframework.database.common.telemetry.
Телеметрию для пула соединений строит DatabaseTelemetryFactory:
DatabaseTelemetry get(DatabaseTelemetryConfig config, String name, String dbType),
где name — имя пула соединений, а dbType — система базы данных, определённая по настройкам соединения.
Для каждого выполняемого запроса драйвер запрашивает у DatabaseTelemetry#observe(QueryContext) объект DatabaseObservation.
Выполняемый запрос описывается QueryContext(queryId, sql, operation), где queryId — стабильный идентификатор запроса,
передаваемый в телеметрию, sql — итоговый текст запроса, а operation по умолчанию равно db_query.
DatabaseObservation расширяет общий контракт Observation и отмечает стадии запроса:
observeConnection()— соединение получено;observeStatement()— запрос подготовлен и начинается выполнение, здесь же логируется начало запроса;span()— спан трассировки запроса;observeError(Throwable)— запрос завершился ошибкой;end()— запрос завершён, в этот момент записывается метрика и логируется завершение запроса.
Фабрика по умолчанию DefaultDatabaseTelemetryFactory объединяет Tracer, MeterRegistry и две необязательные вложенные фабрики:
DefaultDatabaseLoggerFactoryстроит логгер, который пишет начало и завершение запроса (logQueryBegin/logQueryEnd) структурированным аргументомsqlQueryс именем пула, операцией, идентификатором запроса и временем обработки; полный текстsqlпишется только при уровнеTRACE, а упавший запрос логируется на уровнеWARN;DefaultDatabaseMetricsFactoryстроит писателя метрик, который для каждого запроса записывает таймерdb.client.operation.duration.
Если логирование, метрики и трассировка отключены в конфигурации целиком,
используется NoopDatabaseTelemetry, и телеметрия становится пустой операцией.
Помимо стандартных секций logging, metrics и tracing, общий DatabaseTelemetryConfig добавляет одну специфичную для баз данных опцию:
telemetry.metrics.driverMetrics— регистрировать метрики собственного пула соединений драйвера (по умолчанию:true)
Обе вложенные фабрики являются необязательными зависимостями фабрики телеметрии по умолчанию, поэтому проще всего настроить телеметрию, добавив свою вложенную фабрику в граф приложения — никакой аннотации переопределения не требуется:
@KoraApp
public interface Application extends JdbcDatabaseModule {
default DefaultDatabaseLoggerFactory databaseLoggerFactory() { //(1)!
return new DefaultDatabaseLoggerFactory() {
@Override
public DefaultDatabaseLogger create(DefaultDatabaseTelemetry.TelemetryContext context) {
var logger = LoggerFactory.getLogger("my.database." + context.poolName());
return new DefaultDatabaseLogger(logger, context) { //(2)!
@Override
public void logQueryBegin(QueryContext query) {
this.logger.debug("Executing {}", query.queryId());
}
};
}
};
}
}
- Фабрика
DatabaseTelemetryFactoryпо умолчанию принимаетDefaultDatabaseLoggerFactoryиDefaultDatabaseMetricsFactoryкак необязательные зависимости и использует их для каждого пула соединений. DefaultDatabaseLogger— обычный класс, поэтому переопределять нужно только те методы, которые действительно требуется изменить.
@KoraApp
interface Application : JdbcDatabaseModule {
fun databaseLoggerFactory(): DefaultDatabaseLoggerFactory { //(1)!
return object : DefaultDatabaseLoggerFactory() {
override fun create(context: DefaultDatabaseTelemetry.TelemetryContext): DefaultDatabaseLogger {
val logger = LoggerFactory.getLogger("my.database." + context.poolName())
return object : DefaultDatabaseLogger(logger, context) { //(2)!
override fun logQueryBegin(query: QueryContext) {
this.logger.debug("Executing {}", query.queryId())
}
}
}
}
}
}
- Фабрика
DatabaseTelemetryFactoryпо умолчанию принимаетDefaultDatabaseLoggerFactoryиDefaultDatabaseMetricsFactoryкак необязательные зависимости и использует их для каждого пула соединений. DefaultDatabaseLogger— обычный класс, поэтому переопределять нужно только те методы, которые действительно требуется изменить.
DatabaseModule предоставляет фабрику по умолчанию как компонент по умолчанию,
поэтому, если телеметрию нужно заменить целиком, объявите собственную DatabaseTelemetryFactory в
графе приложения — она возьмёт верх над той, что даёт модуль.