Руководство по Kotlin Exposed Framework

Руководство по Kotlin Exposed Framework

1. Вступление

В этом руководстве мы рассмотрим, как запросить реляционную базу данных с помощьюExposed.

Exposed - библиотека с открытым исходным кодом (лицензия Apache), разработанная JetBrains, которая предоставляет идиоматический Kotlin API для некоторых реализаций реляционных баз данных, сглаживая различия между поставщиками баз данных.

Exposed can be used both as a high-level DSL over SQL and as a lightweight ORM (Object-Relational Mapping). Таким образом, в этом руководстве мы рассмотрим оба использования.

2. Открытая настройка фреймворка

Exposed еще нет в Maven Central, поэтому нам нужно использовать специальный репозиторий:


    
        exposed
        exposed
        https://dl.bintray.com/kotlin/exposed
    

Затем мы можем включить библиотеку:


    org.jetbrains.exposed
    exposed
    0.10.4

Кроме того, в следующих разделах мы покажем примеры использования базы данных H2 в памяти:


    com.h2database
    h2
    1.4.197

Мы можем найти последнюю версию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")

Note that invoking connect doesn’t establish a connection to the DB right away. Он просто сохраняет параметры подключения на будущее.

3.1. Дополнительные параметры

Если нам нужно предоставить другие параметры подключения, мы будем использовать другую перегрузку методаconnect, которая дает нам полный контроль над получением подключения к базе данных:

Database.connect({ DriverManager.getConnection("jdbc:h2:mem:test;MODE=MySQL") })

Для этой версииconnect требуется параметр закрытия. Exposed invokes the closure whenever it needs a new connection to the database.

3.2. ИспользуяDataSource

Если вместо этого мы подключаемся к базе данных с помощьюDataSource, как это обычно бывает в корпоративных приложениях (например, чтобы получить выгоду от пула соединений), мы можем использовать соответствующую перегрузкуconnect:

Database.connect(datasource)

4. Открытие транзакции

Каждая операция с базой данных в Exposed требует активной транзакции.

Методtransaction принимает закрытие и вызывает его с активной транзакцией:

transaction {
    //Do cool stuff
}

transaction  возвращает все, что возвращает закрытие. Then, Exposed automatically closes the transaction when the execution of the block terminates.

4.1. Фиксация и откат

Когдаtransaction block успешно возвращается, Exposed фиксирует транзакцию. Когда вместо этого закрытие завершается выдачей исключения, платформа откатывает транзакцию.

Мы также можем вручную зафиксировать или откатить транзакцию. Замыкание, которое мы предоставляем дляtransaction, на самом деле является экземпляром классаTransaction благодаря магии Kotlin.

Таким образом, у нас есть доступный методcommit иrollback:

transaction {
    //Do some stuff
    commit()
    //Do other stuff
}

4.2. Заявления журнала

При изучении инфраструктуры или отладки мы могли бы найти полезным проверить операторы SQL и запросы, которые Exposed отправляет в базу данных.

Мы можем легко добавить такой регистратор в активную транзакцию:

transaction {
    addLogger(StdOutSqlLogger)
    //Do stuff
}

5. Определение таблиц

Обычно в Exposed мы не работаем с необработанными строками и именами SQL. Instead, we define tables, columns, keys, relationships, etc., using a high-level DSL.с

Мы представляем каждую таблицу экземпляром классаTable:

object StarWarsFilms : Table()

Exposed автоматически вычисляет имя таблицы из имени класса, но мы также можем предоставить явное имя:

object StarWarsFilms : Table("STAR_WARS_FILMS")

5.1. Колонны

Таблица не имеет смысла без столбцов. Мы определяем столбцы как свойства нашего класса таблицы:

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. Основные ключи

Как видно из примера в предыдущем разделе,we can easily define indexes and primary keys with a fluent 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()

Запрос представляет собойIterable,, поэтому он поддерживает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") }
  }

Мы также используемslice, чтобы применить функцию к столбцу:

StarWarsFilms.slice(StarWarsFilms.name.countDistinct())

Часто при использовании агрегатных функций, таких какcount иavg,, нам потребуется предложение group by в запросе. Мы поговорим о группе по в разделе 6.5.

6.3. Фильтрация с помощью выражений Where

Exposed contains a dedicated DSL for where expressions, которые используются для фильтрации запросов и других типов операторов. Это мини-язык, основанный на свойствах столбцов, с которыми мы столкнулись ранее, и на серии логических операторов.

Это выражение где:

{ (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())

Благодаря выводу типа нам не нужно подробно описывать сложный тип выражения where, когда оно напрямую передается методуselect, как в приведенном выше примере.

Since where expressions are Kotlin objects, there are no special provisions for query parameters. Мы просто используем переменные:

val sequelNo = 7
StarWarsFilms.select { StarWarsFilms.sequelId >= sequelNo }

6.4. Расширенная фильтрация

ОбъектыQuery, возвращаемыеselect и его вариантами, имеют ряд методов, которые мы можем использовать для уточнения запроса.

Например, мы можем захотеть исключить повторяющиеся строки:

query.withDistinct(true).forEach { ... }

Или мы можем захотеть вернуть только подмножество строк, например, при разбивке на страницы результатов для пользовательского интерфейса:

query.limit(20, offset = 40).forEach { ... }

Эти методы возвращают новыйQuery, поэтому мы можем легко их связать.

6.5. Сортировать по и группировать по

Метод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, но у нас также есть левое, правое и перекрестное соединение, доступное по тому же принципу.

Затем мы можем добавить условия соединения с выражением 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. Aliasing

Благодаря сопоставлению имен столбцов со свойствами, нам не требуется никакого псевдонима в типичном объединении, даже если столбцы имеют одно и то же имя:

(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. Вставка данных

To insert data, we call one of the variants of the insert function. Все варианты закрываются:

StarWarsFilms.insert {
    it[name] = "The Last Jedi"
    it[sequelId] = 8
    it[director] = "Rian Johnson"
}

В замыкании выше участвуют два заметных объекта:

  • this (само замыкание) является экземпляром классаStarWarsFilms; поэтому мы можем получить доступ к столбцам, которые являются свойствами, по их неполному имени.

  • it (параметр закрытия) - этоInsertStatement; it - структура, похожая на карту, со слотом для каждого столбца для вставки

7.2. Извлечение значений столбца с автоинкрементом

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

В типичном случае у нас есть только одно сгенерированное значение, и мы вызываемinsertAndGetId:

val id = StarWarsFilms.insertAndGetId {
    it[name] = "The Last Jedi"
    it[sequelId] = 8
    it[director] = "Rian Johnson"
}
assertEquals(1, id.value)

Если у нас есть более одного сгенерированного значения, мы можем прочитать их по имени:

val insert = StarWarsFilms.insert {
    it[name] = "The Force Awakens"
    it[sequelId] = 7
    it[director] = "J.J. Abrams"
}
assertEquals(2, insert[StarWarsFilms.id]?.value)

7.3. Обновление данных

Теперь мы можем использовать то, что мы узнали о запросах и вставках, для обновления существующих данных в базе данных. Indeed, a simple update looks like a combination of a select with an insert:с

StarWarsFilms.update ({ StarWarsFilms.sequelId eq 8 }) {
    it[name] = "Episode VIII – The Last Jedi"
}

Мы можем видеть использование выражения where в сочетании с закрытиемUpdateStatement. Фактически,UpdateStatement иInsertStatement разделяют большую часть API и логики через общий суперкласс,UpdateBuilder,, который предоставляет возможность устанавливать значение столбца с использованием идиоматических квадратных скобок.

Когда нам нужно обновить столбец, вычислив новое значение из старого, мы используем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. DAO API, легкий ORM

До сих пор мы использовали Exposed для прямого сопоставления операций с объектами Kotlin с запросами и операторами SQL. Каждый вызов метода, напримерinsert, update, select и т. Д., Приводит к немедленной отправке строки SQL в базу данных.

Однако Exposed также имеет высокоуровневый DAO API, который представляет собой простой ORM. Давайте теперь погрузимся в это.

8.1. юридические лица

В предыдущих разделах мы использовали классы для представления таблиц базы данных и для выражения операций над ними с помощью статических методов.

Продвигаясь на шаг дальше, мы можем определить сущности на основе тех классов таблиц, где каждый экземпляр сущности представляет строку базы данных:

class StarWarsFilm(id: EntityID) : Entity(id) {
    companion object : EntityClass(StarWarsFilms)

    var sequelId by StarWarsFilms.sequelId
    var name     by StarWarsFilms.name
    var director by StarWarsFilms.director
}

Давайте теперь проанализируем приведенное выше определение по частям.

В первой строке мы видим, что сущность - это класс, расширяющийEntity. Он имеет идентификатор определенного типа, в данном случаеInt.

class StarWarsFilm(id: EntityID) : Entity(id) {

Затем мы сталкиваемся с определением сопутствующего объекта. The companion object represents the entity class, that is, the static metadata defining the entity and the operations we can perform on it.с

Кроме того, в объявлении сопутствующего объекта мы подключаем сущностьStarWarsFilm – в единственном числе, поскольку она представляет одну строку в таблице,StarWarsFilms - во множественном числе, поскольку она представляет коллекцию всех строк.

companion object : EntityClass(StarWarsFilms)

Наконец, у нас есть свойства, реализованные как делегаты свойств для соответствующих столбцов таблицы.

var sequelId by StarWarsFilms.sequelId
var name     by StarWarsFilms.name
var director by StarWarsFilms.director

Обратите внимание, что ранее мы объявили столбцы сval, потому что они являются неизменяемыми метаданными. Теперь вместо этого мы объявляем свойства объекта с помощьюvar,, потому что они являются изменяемыми слотами в строке базы данных.

8.2. Вставка данных

Чтобы вставить строку в таблицу, мы просто создаем новый экземпляр нашего класса сущности, используя статический фабричный методnew в транзакции:

val theLastJedi = StarWarsFilm.new {
    name = "The Last Jedi"
    sequelId = 8
    director = "Rian Johnson"
}

Note that operations against the database are performed lazily; they’re only issued when the warm cache is flushed. Для сравнения Hibernate вызывает теплый кеш asession.

Это происходит автоматически при необходимости; например, в первый раз, когда мы читаем сгенерированный идентификатор, Exposed молча выполняет оператор вставки:

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, обновление и операции выполняются лениво.

Updates and deletions can only be performed on a previously loaded object. Нет API для массовых обновлений и удалений. Вместо этого мы должны использовать API нижнего уровня, который мы видели в разделе 7. Тем не менее, эти два API могут использоваться вместе в одной транзакции.

8.4. Запросы

С помощью DAO API мы можем выполнять три типа запросов.

Для загрузки всех объектов без условий мы используем статический методall:

val movies = StarWarsFilm.all()

Чтобы загрузить отдельный объект по идентификатору, мы вызываемfindById:

val theLastJedi = StarWarsFilm.findById(1)

Если объекта с таким идентификатором нет,findById возвращаетnull.

Наконец, в общем случае мы используемfind с выражением where:

val movies = StarWarsFilm.find { StarWarsFilms.sequelId eq 8 }

8.5. Много-к-одному Ассоциации

Так же, как соединения являются важной особенностью реляционных баз данных,the mapping of joins to references is an important aspect of an 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): IntEntity(id) {
    companion object : IntEntityClass(UserRatings)

    var value by UserRatings.value
    var film  by StarWarsFilm referencedOn UserRatings.film
    var user  by User         referencedOn UserRatings.user
}

In particular, note the referencedOn infix method call on properties that represent associations. Шаблон следующий: объявлениеvar,by ссылочный объект,referencedOn ссылочный столбец.

Свойства, объявленные таким образом, ведут себя как обычные свойства, но их значением является связанный объект:

val someUser = User.new {
    name = "Some User"
}
val rating = UserRating.new {
    value = 9
    user = someUser
    film = theLastJedi
}
assertEquals(theLastJedi, rating.film)

8.6. Необязательные ассоциации

Связи, которые мы видели в предыдущем разделе, являются обязательными, то есть мы всегда должны указывать значение.

Если нам нужна необязательная связь, мы должны сначала объявить столбец как обнуляемый в таблице:

val user = reference("user", Users).nullable()

Затем мы будем использоватьoptionalReferencedOn вместоreferencedOn в объекте:

var user by User optionalReferencedOn UserRatings.user

Таким образом, свойствоuser будет иметь значение NULL.

8.7. Ассоциации "один ко многим"

Мы могли бы также хотеть нанести на карту противоположную сторону ассоциации. Рейтинг - это фильм, который мы моделируем в базе данных с помощью внешнего ключа; следовательно, у фильма есть несколько рейтингов.

Чтобы сопоставить рейтинги фильма, мы просто добавляем свойство к «первой» стороне ассоциации, то есть к сущности фильма в нашем примере:

class StarWarsFilm(id: EntityID) : Entity(id) {
    //Other properties elided
    val ratings  by UserRating referrersOn UserRatings.film
}

Шаблон аналогичен шаблону отношений многие-к-одному, но используетreferrersOn.. Таким образом определенное свойство -Iterable,, поэтому мы можем пройти его с помощьюforEach:

theLastJedi.ratings.forEach { ... }

Обратите внимание, что, в отличие от обычных свойств, мы определилиratings сval.Indeed, the property is immutable, we can only read it.

Значение свойства также не имеет API для мутации. Итак, чтобы добавить новый рейтинг, мы должны создать его со ссылкой на фильм:

UserRating.new {
    value = 8
    user = someUser
    film = theLastJedi
}

Тогда список фильмаratings будет содержать недавно добавленную оценку.

8.8. Много-ко-много ассоциаций

В некоторых случаях нам может потребоваться ассоциация «многие ко многим». Допустим, мы хотим добавить ссылку на таблицуActors в классStarWarsFilm:

object Actors: IntIdTable() {
    val firstname = varchar("firstname", 50)
    val lastname = varchar("lastname", 50)
}

class Actor(id: EntityID): IntEntity(id) {
    companion object : IntEntityClass(Actors)

    var firstname by Actors.firstname
    var 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) : IntEntity(id) {
    companion object : IntEntityClass(StarWarsFilms)

    //Other properties elided
    var actors by Actor via StarWarsFilmActors
}

На момент написания невозможно создать объект со сгенерированным идентификатором и включить его в ассоциацию "многие ко многим" в той же транзакции.

На самом деле, мы должны использовать несколько транзакций:

//First, create the film
val film = transaction {
   StarWarsFilm.new {
    name = "The Last Jedi"
    sequelId = 8
    director = "Rian Johnson"r
  }
}
//Then, create the actor
val actor = transaction {
  Actor.new {
    firstname = "Daisy"
    lastname = "Ridley"
  }
}
//Finally, link the two together
transaction {
  film.actors = SizedCollection(listOf(actor))
}

Здесь для удобства мы использовали три разные транзакции. Однако двух было бы достаточно.

9. Заключение

В этой статье мы подробно рассмотрели фреймворк Exposed для Kotlin. Для получения дополнительной информации и примеров см.Exposed wiki.

Реализация всех этих примеров и фрагментов кода может быть найдена вthe GitHub project как проект Maven, поэтому его будет легко импортировать и запускать как есть.