Перевод подготовлен в рамках набора учащихся на курс "Kotlin Backend Developer".
Также приглашаем всех желающих на демо-урок Объектно-ориентированное программирование в Kotlin. Цели занятия:
- узнать про элементы объектной модели Kotlin;
- создавать различные классы и объекты;
- выполнять наследование и делегирование;
- пользоваться геттерами и сеттерами.
1. Введение
В этой статье мы рассмотрим, как направлять запросы к реляционной базе данных с помощью Exposed.
Exposed это открытая библиотека, разработанная компанией JetBrains. Она распространяется по лицензии Apache и позволяет использовать идиоматический API Kotlin для реализации некоторых реляционных баз данных от различных поставщиков.
Exposed можно использовать как в качестве высокоуровневого языка DSL в SQL, так и в качестве облегченной технологии ORM (объектно-реляционного отображения). В этом руководстве мы рассмотрим оба варианта использования.
2. Установка
Фреймворк Exposed еще не опубликован в Maven Central, потому нам придется использовать отдельный репозиторий:
<repositories> <repository> <id>exposed</id> <name>exposed</name> <url>https://dl.bintray.com/kotlin/exposed</url> </repository></repositories>
Теперь можно подключить библиотеку:
<dependency> <groupId>org.jetbrains.exposed</groupId> <artifactId>exposed</artifactId> <version>0.10.4</version></dependency>
Ниже мы приведем несколько примеров использования базы данных H2 в памяти:
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>1.4.197</version></dependency>
Последняя версия Exposed доступна на Bintray, а последняя версия H2 на Maven Central.
3. Соединение с базой данных
Для того чтобы установить соединение с базой данных, будем
использовать класс Database
:
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")
Мы можем также указать пользователя (user) и пароль (password) в качестве именованных параметров:
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver",user = "myself", password = "secret")
Обратите внимание: вызов метода
connect
не устанавливает соединения с БД
сразу. Соединение будет установлено позже с использованием
сохраненных параметров.
3.1. Дополнительные параметры
Для того чтобы задать другие параметры соединения, мы будем
использовать перегруженный метод connect
, который
позволит нам полностью контролировать подключение:
Database.connect({ DriverManager.getConnection("jdbc:h2:mem:test;MODE=MySQL") })
В этом случае нам придется использовать замыкание. Exposed вызывает замыкание при необходимости установления нового соединения с базой данных.
3.2. Подключение через DataSource
Если мы будем подключаться к базе данных с использованием
DataSource
(именно этот подход обычно используется в
корпоративных приложениях, например, чтобы иметь преимущества пула
подключений), нам потребуется соответствующий перегруженный метод
connect
:
Database.connect(datasource)
4. Открытие транзакции
Все операции с базами данных в Exposed выполняются только при наличии активных транзакций.
Метод transaction
принимает замыкание и вызывает его
в активной транзакции.
transaction {//Do cool stuff}
Метод transaction
возвращает значение, которое
вернуло замыкание.После выполнения блока Exposed
автоматически закрывает транзакцию.
4.1. Подтверждение и откат транзакций
После успешного выполнения блока с помощью метода transaction Exposed подтверждает транзакцию. Если же замыкание генерирует исключение, фреймворк откатывает транзакцию.
Подтверждение и откат транзакции можно выполнить в ручном
режиме. В Kotlin замыкание, которое мы передали в методе
transaction
, фактически является экземпляром класса
Transaction
.
Таким образом, мы можем использовать метод
commit
илиrollback
:
transaction {//Do some stuffcommit()//Do other stuff}
4.2. Запись инструкций в журнал
При изучении фреймворка или отладке кода не лишним будет отследить инструкции и запросы SQL, которые Exposed отправляет в базу данных.
Для этого добавим регистратор к активной транзакции:
transaction { addLogger(StdOutSqlLogger) //Do stuff}
5. Определение таблиц
Как правило, Exposed не используется для работы с неформатированными строками и именами SQL. Мы определяем таблицы, столбцы, ключи, связи и т.д. с помощью высокоуровневого DSL.
AD
Для представления каждой таблицы будем использовать экземпляр
класса Table
:
object StarWarsFilms : Table()
Exposed автоматически присваивает таблице имя на основании имени класса, но мы можем задать имя самостоятельно:
object StarWarsFilms : Table("STAR_WARS_FILMS")
5.1. Столбцы
Без столбцов таблица работать не будет. Определим столбцы как
свойства класса Table
:
object StarWarsFilms : Table() {val id = integer("id").autoIncrement().primaryKey()val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}
Для краткости мы не указали типы Kotlin определит их
автоматически. В любом случае каждая колонка относится к классу
Column<T>
, у нее есть имя, тип и, возможно,
параметры типа.
5.2. Первичные ключи
В предыдущем разделе мы рассмотрели пример, где индексы и первичные ключи определяются с помощью текучего API.
Однако, если в таблице в качестве первичного ключа используется
целое число, мы можем использовать встроенные в Exposed классы
IntIdTable
и LongIdTable
для определения
ключей:
object StarWarsFilms : IntIdTable() {val sequelId = integer("sequel_id").uniqueIndex()val name = varchar("name", 50)val director = varchar("director", 50)}
Есть также класс UUIDTable
, а еще мы можем
определить собственные варианты, выделив подклассы в классе
IdTable
.
5.3. Внешние ключи
Добавить внешний ключ очень просто. Мы можем использовать статическую типизацию, поскольку мы всегда обращаемся к свойствам, которые были известны в момент создания таблицы.
Предположим, что нам нужно узнать имена актеров, которые снимались в каждом фильме:
object Players : Table() {val sequelId = integer("sequel_id").uniqueIndex().references(StarWarsFilms.sequelId)val name = varchar("name", 50)}
Для того чтобы не прописывать тип столбца (в этом примере
integer), который можно получить из связанного столбца,
воспользуемся методом reference
:
val sequelId = reference("sequel_id", StarWarsFilms.sequelId).uniqueIndex()
Если мы ссылаемся на первичный ключ, имя столбца можно не указывать:
val filmId = reference("film_id", StarWarsFilms)
5.4. Создание таблиц
Таблицы можно создавать программно, как указано выше:
transaction {SchemaUtils.create(StarWarsFilms, Players)//Do stuff}
Таблицу можно создать, только если она не существует. Однако миграция баз данных не поддерживается.
6. Запросы
Определив классы таблиц, как показано выше, мы можем направлять запросы к базе данных с использованием функций расширения, встроенных в фреймворк.
6.1. Выбор всех объектов
Для того чтобы извлечь данные из базы, будем использовать
объекты Query
, созданные на основе классов таблиц.
Самый простой запрос будет возвращать все строки заданной
таблицы:
val query = StarWarsFilms.selectAll()
Запрос является итерируемым и поддерживает циклы
forEach
:
query.forEach {assertTrue { it[StarWarsFilms.sequelId] >= 7 }}
Параметр замыкания, которому в нашем примере присвоено имя
it
, это экземпляр класса ResultRow
. В
результате столбцам присваиваются ключи.
6.2. Выбор подмножества столбцов
Мы можем также выбрать подмножество столбцов таблицы, тоесть
выполнить проекцию, с помощью метода slice
:
StarWarsFilms.slice(StarWarsFilms.name, StarWarsFilms.director).selectAll().forEach {assertTrue { it[StarWarsFilms.name].startsWith("The") }}
Этот метод позволяет применить функцию к столбцу:
StarWarsFilms.slice(StarWarsFilms.name.countDistinct())
Часто при использовании агрегатных функций, например
count
и avg
, направляя запрос, мы
используем группировку по оператору. О группах мы поговорим в
разделе 6.5.
6.3. Фильтрация с помощью выражения where
В Exposed для выражений where, которые используются для фильтрации запросов и других типов инструкций, используется специальный DSL. В основе этого мини-языка лежат свойства столбцов, с которыми мы познакомились ранее, и серия логических операторов.
Вот пример выражения where
:
{ (StarWarsFilms.director like "J.J.%") and (StarWarsFilms.sequelId eq 7) }
Оно относится к комплексному типу и является подклассом
SqlExpressionBuilder
, который определяет такие
операторы, как like
,
eq
, and
. Как видим, это
последовательность операций сравнения, соединенных операторами
and
и or
.
Мы можем передать такое выражение в метод select
,
который вернет очередной запрос:
val select = StarWarsFilms.select { ... }assertEquals(1, select.count())
Поскольку тип может быть выведен из контекста, нам не
обязательно указывать тип complex
для выражения
where
, когда оно передается непосредственно в метод
select
, как в рассмотренном примере.
В Kotlin выражения с where являются объектами, поэтому специальных параметров для запросов нет. Мы используем переменные:
val sequelNo = 7StarWarsFilms.select { StarWarsFilms.sequelId >= sequelNo }
6.4. Дополнительная фильтрация
Существует несколько методов для уточнения запросов, которые
возвращает метод select
и его эквиваленты.
Например, можно удалить повторяющиеся строки:
query.withDistinct(true).forEach { ... }
Или вернуть только подмножество строк, например в случае нумерации страниц с результатами при работе над пользовательским интерфейсом:
query.limit(20, offset = 40).forEach { ... }
Эти методы будут возвращать новые объекты Query, поэтому мы можем выстроить их вызовы в цепочку.
6.5.Методы orderByи groupBy
Метод Query.orderBy
принимает список столбцов,
связанных со значением SortOrder
, которое задает тип
сортировки элементов по возрастанию или по убыванию:
query.orderBy(StarWarsFilms.name to SortOrder.ASC)
Группировка по одному или нескольким столбцам будет особенно
полезна при использовании агрегатной функции (см. раздел 6.2). Для
этого воспользуемся методом groupBy
:
StarWarsFilms.slice(StarWarsFilms.sequelId.count(), StarWarsFilms.director).selectAll().groupBy(StarWarsFilms.director)
6.6. Соединения
Соединения это, пожалуй, одна из самых важных характеристик реляционной базы данных. Вот самый простой пример. Если мы знаем внешний ключ и у нас нет условий соединения, мы можем воспользоваться встроенными операторами соединения:
(StarWarsFilms innerJoin Players).selectAll()
В этом примере мы использовали оператор innerJoin
,
но по этому же принципу можно использовать операторы LEFT
JOIN
, RIGHT JOIN
и CROSS JOIN
.
Затем можно добавить условия соединения, используя выражение
where
; например, если у нас нет внешнего ключа,
придется указать явную операцию соединения:
(StarWarsFilms innerJoin Players).select { StarWarsFilms.sequelId eq Players.sequelId }
В общем случае операция соединения полностью записывается так:
val complexJoin = Join(StarWarsFilms, Players,onColumn = StarWarsFilms.sequelId, otherColumn = Players.sequelId,joinType = JoinType.INNER,additionalConstraint = { StarWarsFilms.sequelId eq 8 })complexJoin.selectAll()
6.7. Псевдонимы
Благодаря тому что имена столбцов связаны со свойствами, при типичном соединении нам не придется присваивать им псевдонимы, даже если у некоторых столбцов имена совпадают:
(StarWarsFilms innerJoin Players).selectAll().forEach {assertEquals(it[StarWarsFilms.sequelId], it[Players.sequelId])}
На самом деле в этом примере
StarWarsFilms.sequelId
и Players.sequelId
это разные столбцы.
Однако, если в запросе одна и та же таблица появляется несколько
раз, можно присвоить ей псевдоним. Для этого воспользуемся функцией
alias
:
val sequel = StarWarsFilms.alias("sequel")
Псевдоним можно указывать в качестве названия таблицы:
Join(StarWarsFilms, sequel,additionalConstraint = {sequel[StarWarsFilms.sequelId] eq StarWarsFilms.sequelId + 1}).selectAll().forEach {assertEquals(it[sequel[StarWarsFilms.sequelId]], it[StarWarsFilms.sequelId] + 1)}
В этом примере sequel
это таблица, которая
участвует в операции соединения. Чтобы обратиться к столбцу, в
качестве ключа будем использовать столбец таблицы, представленной
псевдонимом:
sequel[StarWarsFilms.sequelId]
7. Инструкции
Мы рассмотрели, как выполнять запросы к базе данных. Теперь разберемся с DML-инструкциями.
7.1. Вставка данных
Для того чтобы вставить данные, вызовем функцию, эквивалентную функции insert. Все они принимают замыкание:
StarWarsFilms.insert {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}
В этом замыкании используются два объекта:
-
this
(само замыкание) это экземпляр классаStarWarsFilms
; именно этот объект позволяет нам обращаться к столбцам, которые являются свойствами, по неуточненному имени; -
it
(параметр замыкания) этоInsertStatement
; это структура, аналогичная коллекции ключ/значение, в которой есть слоты для вставки столбцов.
7.2. Извлечение автоинкрементного значения столбцов
Если у нас есть инструкция insert с автоматически генерируемыми столбцами (обычно это автоматическое увеличение индекса или последовательности), мы можем извлечь сгенерированные значения.
В типичном сценарии есть только одно сгенерированное значение.
Воспользуемся методом insertAndGetId
:
val id = StarWarsFilms.insertAndGetId {it[name] = "The Last Jedi"it[sequelId] = 8it[director] = "Rian Johnson"}assertEquals(1, id.value)
Если у нас несколько сгенерированных значений, их можно считывать по имени:
val insert = StarWarsFilms.insert {it[name] = "The Force Awakens"it[sequelId] = 7it[director] = "J.J. Abrams"}assertEquals(2, insert[StarWarsFilms.id]?.value)
7.3. Обновление данных
Мы научились выполнять запросы и вставлять данные. Теперь перейдем к обновлению данных, которые содержатся в базе. Самый простой способ обновления похож на комбинацию методов select и insert:
StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {it[name] = "Episode VIII The Last Jedi"}
В этом примере выражение where используется вместе с замыканием
UpdateStatement
. UpdateStatement
и
InsertStatement
это потомки класса
UpdateBuilder
, поэтому в них используется один и тот
же API и одна и та же логика. Родительский класс позволяет задать
значение столбца с помощью квадратных скобок.
Если для обновления столбца нам нужно вычислять новое
значение из старого, воспользуемся
SqlExpressionBuilder
:
StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {with(SqlExpressionBuilder) {it.update(StarWarsFilms.sequelId, StarWarsFilms.sequelId + 1)}}
Этот объект позволяет использовать инфиксный оператор (например, plus,minusи т. д.) для создания инструкции обновления.
7.4. Удаление данных
И наконец, мы можем удалить данные с помощью метода
deleteWhere
:
StarWarsFilms.deleteWhere ({ StarWarsFilms.sequelId eq 8 })
8. API DAO, облегченная технология ORM
Мы использовали Exposed для того, чтобы связать операции над
объектами Kotlin с запросами и инструкциями SQL напрямую. Такие
методы, как insert
,
update
, select
и т. д.,
немедленно отправляют строку SQL в базу данных.
Однако в Exposed есть высокоуровневый API DAO, который представляет собой простую технологию ORM. Давайте рассмотрим его подробнее.
8.1. Сущности
В рассмотренных выше примерах мы использовали классы для представления таблиц базы данных и описания операций над ними с использованием статических методов.
Теперь мы можем определить сущности на основе этих классов таблиц, где каждый экземпляр сущности представляет собой строку базы данных:
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)var sequelId by StarWarsFilms.sequelIdvar name by StarWarsFilms.namevar director by StarWarsFilms.director}
Давайте подробно проанализируем это определение.
Из первой строки видно, что сущность это класс, расширяющий
Entity
. У нее есть ID специфического типа, в нашем
случае Int
.
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {
Затем определяем объект-компаньон.Объект-компаньон это класс сущности, то есть статические метаданные, которые определяют сущность и операции, которые мы можем выполнять над ней.
Объявляя объект-компаньон, мы соединяем сущность с именем
StarWarsFilm
(в единственном числе), которая
представляет собой одну строку, с таблицей с именем
StarWarsFilms
(во множественном числе), которая
представляет собой коллекцию всех строк.
companion object : EntityClass<Int, StarWarsFilm>(StarWarsFilms)
Наконец, мы задаем свойства с помощью делегатов свойств для соответствующих столбцов таблицы.
var sequelId by StarWarsFilms.sequelIdvar name by StarWarsFilms.namevar director by StarWarsFilms.director
Обратите внимание, что раньше мы объявляли столбцы с помощью val, поскольку они являются неизменяемыми метаданными. Теперь же мы объявляем свойства сущности с помощью var, поскольку они являются изменяемыми слотами в строке базы данных.
8.2. Вставка данных
Чтобы вставить строку в таблицу, нам нужно просто создать
экземпляр класса сущности с использованием статического фабричного
метода new
в транзакции:
val theLastJedi = StarWarsFilm.new {name = "The Last Jedi"sequelId = 8director = "Rian Johnson"}
Обратите внимание: с базой данных выполняются отложенные операции, которые запускаются только после выполнения warm cache. В Hibernate, например, теплый кэш привязан к сессии (session).
Операция выполняется автоматически. Например, когда мы в первый
раз считываем сгенерированный идентификатор, Exposed
выполняет инструкцию insert
:
assertEquals(1, theLastJedi.id.value) //Reading the ID causes a flush
Сравните это поведение с методом insert
, который мы
рассматривали в разделе 7.1, в этом примере метод сразу же
выполняет инструкцию в базе данных. Здесь же мы работаем на более
высоком уровне абстракции.
8.3. Обновление и удаление объектов
Для обновления строк нужно просто задать их свойства:
theLastJedi.name = "Episode VIII The Last Jedi"
Для удаления объекта вызовем метод delete
этого
объекта:
theLastJedi.delete()
Так же, как при использовании метода new
,
обновление и операции выполняются в отложенном режиме.
Обновить и удалить можно только ранее загруженный объект. В этом фреймворке нет API для обновления и удаления нескольких объектов, и нам придется использовать API более низкого уровня (см. раздел 7). Тем не менее в одной транзакции можно одновременно использовать и тот и другой API.
8.4. Запросы
API DAO позволяет выполнять три типа запросов.
Для загрузки всех объектов, для которых не заданы условия, будем
использовать статический метод all
:
val movies = StarWarsFilm.all()
Для загрузки одного объекта по ID воспользуемся методом
findById
:
val theLastJedi = StarWarsFilm.findById(1)
Если объекта с таким ID нет, findById
вернет
значение null
.
В самом общем случае мы можем использовать метод findс выражением where:
val movies = StarWarsFilm.find { StarWarsFilms.sequelId eq 8 }
8.5. Связь многие к одному
В ORM соотнесение соединений со ссылками так же важно, как соединения в реляционных базах данных. Посмотрим, какие возможности предлагает Exposed.
Предположим, что нам нужно узнать пользовательский рейтинг каждого фильма. Сначала определим две дополнительные таблицы:
object Users: IntIdTable() {val name = varchar("name", 50)}object UserRatings: IntIdTable() {val value = long("value")val film = reference("film", StarWarsFilms)val user = reference("user", Users)}
Затем создадим соответствующие сущности. Опустим сущность
User (это очевидно) и перейдем сразу к классу
UserRating
:
class UserRating(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<UserRating>(UserRatings)var value by UserRatings.valuevar film by StarWarsFilm referencedOn UserRatings.filmvar user by User referencedOn UserRatings.user}
Обратите внимание: инфиксный метод referencedOn
вызывает свойства, которые представляют собой связи.Модель
следующая: объявляем переменную var
через сущность
(by
) со ссылкой на соответствующий столбец
(referencedOn
).
Свойства, объявленные таким образом, ведут себя как обычные свойства, но их значением является связанный объект:
val someUser = User.new {name = "Some User"}val rating = UserRating.new {value = 9user = someUserfilm = theLastJedi}assertEquals(theLastJedi, rating.film)
8.6. Дополнительные связи
Рассмотренные выше связи являются обязательными мы должны всегда указывать значение.
Чтобы установить дополнительные связи, нам нужно сначала
разрешить столбцу таблицы принимать значение null
:
val user = reference("user", Users).nullable()
Вместо метода referencedOn
будем использовать
optionalReferencedOn
:
var user by User optionalReferencedOn UserRatings.user
Таким образом, свойство user
сможет принимать
значение null
.
8.7. Связь один ко многим
Мы можем создать обратную связь с помощью внешнего ключа. Будем использовать его для моделирования рейтинга фильма в базе данных; у фильма будет несколько рейтингов.
Для отображения рейтингов нужно добавить свойство к той стороне
связи, где используется один объект. В нашем случае это сущность
film
:
class StarWarsFilm(id: EntityID<Int>) : Entity<Int>(id) {//Other properties elidedval ratings by UserRating referrersOn UserRatings.film}
Модель похожа на модель связи многие к одному, но сейчас мы
используем метод referrersOn
.Свойство, определенное
таким образом, является итерируемым, поэтому мы можем выполнить
обход с помощью цикла forEach
:
theLastJedi.ratings.forEach { ... }
В отличие от обычных свойств, здесь мы определили
ratings
черезval
.Свойство
неизменяемо, поэтому мы можем только считывать его.
У значения свойства тоже нет API для изменения. Поэтому для добавления нового рейтинга нам нужно создать его, указав ссылку на фильм:
UserRating.new {value = 8user = someUserfilm = theLastJedi}
Теперь новый рейтинг появится в перечне рейтингов для этого фильма.
8.8. Связь многие ко многим
Иногда требуется установить связь многие ко многим. Предположим,
что нам нужно связать класс StarWarsFilm
с таблицей
Actors
:
object Actors: IntIdTable() {val firstname = varchar("firstname", 50)val lastname = varchar("lastname", 50)}class Actor(id: EntityID<Int>): IntEntity(id) {companion object : IntEntityClass<Actor>(Actors)var firstname by Actors.firstnamevar lastname by Actors.lastname}
После того как мы определим таблицу и сущность, нужно будет создать другую таблицу для установления связи:
object StarWarsFilmActors : Table() {val starWarsFilm = reference("starWarsFilm", StarWarsFilms).primaryKey(0)val actor = reference("actor", Actors).primaryKey(1)}
В этой таблице два столбца, каждый из которых является внешним ключом, а вместе они представляют собой сложный первичный ключ.
Теперь можно подключить таблицу связей к сущности
StarWarsFilm
:
class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) {companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms)//Other properties elidedvar actors by Actor via StarWarsFilmActors}
На момент написания статьи создать сущность с генерируемым идентификатором и использовать ее в связи многие ко многим в одной транзакции нельзя.
Нам приходится выполнять несколько транзакций:
//First, create the filmval film = transaction { StarWarsFilm.new { name = "The Last Jedi" sequelId = 8 director = "Rian Johnson"r }}//Then, create the actorval actor = transaction { Actor.new { firstname = "Daisy" lastname = "Ridley" }}//Finally, link the two togethertransaction { film.actors = SizedCollection(listOf(actor))}
В этом примере мы для удобства выполнили три транзакции, хотя двух было бы достаточно.
9. Заключение
В этой статье мы подробно рассмотрели фреймворк Kotlin Exposed. Дополнительную информацию и примеры можно найти в вики-учебнике по Exposed.
Варианты реализации рассмотренных примеров и фрагменты кода можно найти на GitHub.
Узнать подробнее о курсе "Kotlin Backend Developer".
Смотреть вебинар Объектно-ориентированное программирование в Kotlin.