Table API (репозиторные сущности).
Код:
object UserTable : IdTable<UUID>("users") {
override val id = uuid("id").entityId()
val name = varchar("name", 512).nullable()
}
object ContactTable : IdTable<UUID>("contacts") {
override val id = ContactEntity.uuid("id").entityId()
val type = varchar("type", 128)
val value = varchar("value", 256).nullable()
val userId = reference("user_id", UserEntity)
}
Здесь необходимо пояснение.
Несомненный плюс фреймворка состоит в том, что репозиторные сущности создаются из синглтонов вне орбиты Spring и могут быть использованы в любом месте приложения, не ограничиваясь компонентами Spring - в качестве примера наши маперы не будут компонентами Spring, но при этом вовсю будут использовать API Exposed.
Также, при создании Table API Exposed не генерирует дополнительных классов, как это делает JOOQ - и это тоже несомненный плюс. Вы просто описываете репозиторную сущность и работаете с ней. Единственно, Вам нужно унаследовать Вашу сущность от org.jetbrains.exposed.sql.Table.
У Table существует наследник IdTable, а у него - ещё несколько, которые задают тип id в наиболее популярном диапазоне значений:
Table, его наследники и внуки
Мы могли бы сразу использовать UUIDTable вместо IdTable<UUID>, но тогда нам пришлось бы отдать фреймворку управление генерацией первичного ключа, а у нас первичный ключ генерируется в базе данных.
Мапинг между сущностями.
В этой части будет обещанная демонстрация работы Table API вне пределов Спринга.
Отличие Exposed от других фреймворков для работы с базой данных заключается в том, что репозиторную сущность нельзя набить данными и таким образом отправить их в базу или получить оттуда данные. Она лишь предоставляет API для работы с той или иной таблицей. Для транспортировки данных из бизнес-сущности в таблицу и из таблицы в бизнес-сущность существуют две структуры: ResultRow (для получения данных из базы) и Statement (для отправки данных в базу).
Мапинг из бизнес-сущности.
Для транспортировки данных в базу существует абстрактный класс Statement. Через цепочку наследований его реализуют, в том числе, InsertStatement и UpdateStatement, которые мы рассмотрим в рамках транспортировки данных в запросах insert и update.
Мы рассмотрим два подхода передачи данных - через мапер (на примере ContactMapper) и более лаконичный, напрямую в insert и update-запросах (на примере UserRepository).
В любом случае, функции insert и update используют Statement как параметр. Напишем функции, которые будут наполнять реализации Statement значениями из бизнес-сущности.
В проекте маперы разложены по отдельным файлам, равно как и все сущности, но для наглядности будет удобным разместить маперы в одном кодовом блоке, благо они у нас гомеопатических размеров.
Код:
fun Contact.toInsertStatement(statement: InsertStatement<Number>): InsertStatement<Number> = statement.also {
it[ContactTable.type] = this.type.name
it[ContactTable.value] = this.value
it[ContactTable.userId] = this.userId
}
fun Contact.toUpdateStatement(statement: UpdateStatement): UpdateStatement = statement.also {
it[ContactTable.type] = this.type.name
it[ContactTable.value] = this.value
it[ContactTable.userId] = this.userId
}
Statement содержит в себе Map<Column<*>, Any?>, что позволяет нам наполнить колонки значениями из бизнес-сущности. Всё просто.
Мапинг в бизнес-сущность.
В качестве результата запроса Exposed возвращает List<ResultRow> как массив значений колонок строки. Задача мапера - намапить данные колонок, полученные в ResultRow, на бизнес-сущность.
Код:
fun ResultRow.toUser(): User = User(
id = this[UserEntity.id].value,
name = this[UserEntity.name],
contacts = this[UserEntity.id].value.let {
ContactEntity.select { ContactEntity.userId eq it }.map { it.toContact() }
}
)
fun ResultRow.toContact(): Contact = Contact(
id = this[ContactEntity.id].value,
type = ContactType.valueOf(this[ContactEntity.type]),
value = this[ContactEntity.value],
userId = this[ContactEntity.userId].value
)
Мы берём ResultRow, достаём значение по типу колонки и присваиваем полученное значение нужному полю.
И всё.
Что же касается поля User.contacts, здесь можно пойти несколькими путями. Можно через джоин таблиц, а можно через подзапрос. Я описал способ с подзапросом как наиболее простой. В проекте, который Вы скачаете в конце поста, есть также способ получения множества через джоин таблиц.
Итак, мы полностью подготовили проект к самой сути поста - выполнению запросов в базу данных.
Запросы в базу данных.
Первое и беспрекословное правило выполнения запросов через Exposed: все запросы явно должны вызываться под транзакцией. Тут два пути: вызов функции-замыкания transaction, вот таким образом:
Код:
override fun get(id: UUID): User? = transaction {}
или через спринговую аннотацию:
Код:
@Transactional
override fun delete(id: UUID) {}
В проекте-примере реализованы оба подхода, но я предпочитаю первый.
SELECT
Открыв транзакцию, мы пишем сам запрос. Семантика запроса напоминает SQL. Например:
Код:
override fun get(id: UUID): User? = transaction {
UserEntity.select { UserEntity.id eq id }.firstOrNull()?.toUser()
}
Запрос выше будет соответствовать SQL-запросу
Код:
select * from users where id = ?
с дальнейшим получением первого результата и мапингом в User.
Да, на данный момент, Exposed не понимает, сколько строчек мы хотим получить в ответе - одну или множество (как это понимает, к примеру, JOOQ с его fetch(), fetchOne() и fetchOptional()). Равно как обычный SQL-запрос вернёт нам множество в ответ на select-запрос, так это сделает и Exposed. В таком случае, приходится применять костылёк в виде first() или firstOrNull(). Или установить в запросе limit(1), как делает под капотом тот же JOOQ.
Запрос на получение нескольких записей будет, в целом, проще:
Код:
override fun getAll(limit: Int): List<User> = transaction {
UserEntity.selectAll()
.limit(limit)
.map { it.toUser() }
}
В примере я указал limit, но этот параметр необязателен. Другой пример:
Код:
override fun getAll(userId: UUID): List<Contact> = transaction {
ContactEntity.select { ContactEntity.userId eq userId }
.map { it.toContact() }
}
DELETE
Код:
override fun delete(id: UUID) {
transaction {
ContactEntity.deleteWhere { ContactEntity.id eq id }
}
}
Такой запрос возвращает количество записей, удалённых по условию.
INSERT
Код:
override fun insert(contact: Contact): Contact = transaction {
ContactEntity.insert { contact.toInsertStatement(it) }
.resultedValues?.first()?.toContact()
?: throw NoSuchElementException("Error saving user: ${objectMapperKt.writeValueAsString(contact)}")
}
Как уже было описано в разделе маперов, функция insert() является функцией высшего порядка и использует в качестве параметра функцию с единственным параметромInsertStatement.
Код:
fun <T : Table> T.insert(body: T.(InsertStatement<Number>) -> Unit): InsertStatement<Number>
Всё, что нам остаётся сделать - это замапить бизнес-сущность в InsertStatement, что мы и сделали ранее в мапере. InsertStatement содержит также два поля: insertedCount и resultedValues, первое из которых возвращает количество добавленных в базу записей, а второе - сами записи. В нашем примере мы не будем использовать insertedCount, а вот resultedValues нам пригодится. Сохранённые данные возвращаются в уже привычной структуре List<ResultRow>, что очень удобно - мы можем переиспользовать мапер из таблицы в бизнес-сущность, написанный ранее.
Поскольку функция insert является лямбдой, мы можем существенно сократить количество кода и проинициализировать поля InsertStatement прямо в функции (что я и обещал сделать в разделе маперов на примере UserRepository):
Код:
override fun insert(user: User): User = transaction {
UserTable.insert {
it[name] = user.name
}
.resultedValues?.first()?.toUser()
?: throw NoSuchElementException(
"Error saving user: ${objectMapperKt.writeValueAsString(user)}. Statement result is null."
)
}
Да, мы просто берём единственный параметр функции insert() и инициализируем его поля. Всё просто.
UPDATE
Функция update() отличается от функции insert() тем, что в ней - три параметра вместо одного.
Код:
fun <T : Table> T.update(where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null, limit: Int? = null, body: T.(UpdateStatement) -> Unit): Int
Параметр limit мы использовать не будем, нам интересны два других, каждый из которых также является функцией. В первой функции мы указываем условия, по которым будут отобраны записи для обновления, во второй - саму суть обновления. Выглядит это так:
override fun update(contact:
Код:
Contact) {
transaction {
ContactTable.update({ ContactTable.id eq contact.id!! }) { contact.toUpdateStatement(it) }
}
}
Это в случае, если у нас есть мапер в Statement. Если мапера нет, можно также осуществить мапинг напрямую, как мы это делали с insert().
Код:
override fun update(user: User): User = transaction {
UserTable.update({ UserTable.id eq user.id }) {
it[name] = user.name
}
UserTable.select { UserTable.id eq user.id }
.firstOrNull()?.toUser()
?: throw NoSuchElementException(
"Error updating user: ${objectMapperKt.writeValueAsString(user)}. Statement result is null."
)
}
Заключение
Вот, собственно, и всё.
Конечно, существуют самые разные практики, да и функций для взаимодействия с базой данных гораздо больше. Разобравшись самостоятельно, Вы наверняка обнаружите batchInsert(), deleteIgnoreWhere(), andWhere() и многие другие - фреймворк покрывает стандартные запросы разработчика чуть более чем полностью.
Для просмотра ссылки Войди или Зарегистрируйся