Как написать SQL-запрос на Slick и не открыть портал в ад

в 16:01, , рубрики: java, orm, scala, Slick, sql, Блог компании Группа компаний «ЦИТ» (Центр ИТ), монады, функциональное программирование, метки:

Как написать SQL-запрос на Slick и не открыть портал в ад - 1

Slick — это не только фамилия одной из величайших солисток всех времён, но и название популярного Scala-фреймворка для работы с базами данных. Этот фреймворк исповедует «функционально-реляционный маппинг», реализует реактивные паттерны и обладает официальной поддержкой Lightbend. Однако отзывы разработчиков о нём, прямо скажем, смешанные — многие считают его неоправданно сложным, и это отчасти обоснованно. В этой статье я поделюсь своими впечатлениями о том, на что стоит обратить внимание при его использовании начинающему Scala-разработчику, чтобы в процессе написания запросов случайно не открыть портал в ад.

Фреймворк Slick, как это часто случается в мире Scala, сравнительно недавно пережил существенный редизайн — версия 3 была заточена под реактивность и сильно поменяла API, сделав его ещё более функциональным, чем прежде — и теперь большое количество статей и ответов на StackOverflow, рассчитанных на версию 2, стало неактуальным. Документация на фреймворк достаточно лаконичная и представляет собой скорее список примеров; концептуальные вещи (в частности, активное использование монад) в ней объясняются достаточно поверхностно. Предполагается, что многие аспекты функционального программирования на Scala и продвинутые фичи языка разработчику уже хорошо известны.

Результатом стали подобные вопросы на StackOverflow, за которые мне теперь немного стыдно: там я бился над некомпилирующимся кодом, потому что не понимал архитектуры фреймворка и тех монадических паттернов, которые в нём заложены. Об этих паттернах и их применении в Slick мне и хотелось бы рассказать в этой статье: возможно, кому-то они сберегут многие часы мучений в попытках написать что-то более сложное, чем простейший запрос.

Монады и построитель запросов

Одним из важных компонентов любой типобезопасной библиотеки для работы с базами данных является построитель запросов, который позволяет из типизированного кода на языке программирования сформировать нетипизированную строку на языке SQL. Вот пример построения запроса с использованием Slick, взятый из документации, из раздела про «монадические джойны»:

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)
// compiles to SQL:
//   select x2."COF_NAME", x3."SUP_NAME"
//     from "COFFEES" x2, "SUPPLIERS" x3
//     where x2."SUP_ID" = x3."SUP_ID"

Признаюсь, для новичка в Scala это выглядело довольно странно. Если долго медитировать на этот код, то можно заметить соответствия между этой хитрой синтаксической конструкцией и приведённым ниже SQL-запросом, в который она трансформируется. Вроде что-то становится понятно: справа от стрелочек таблицы, слева — алиасы, после if — условие, в yield — поля, выбранные для проекции. Выглядит как SQL-запрос, вывернутый наизнанку. Но почему построитель реализован именно так? При чём тут вообще for? Разве здесь есть какая-то итерация по содержимому таблиц? Ведь в этот момент мы ещё не исполняем запрос, а только строим его.

Без понимания того, как эта конструкция работает, конечно, можно привыкнуть к такому синтаксису и клепать подобные запросы по аналогии. Но при попытке написать что-то более сложное мы рискуем натолкнуться на стену непонимания и потратить кучу времени, проклиная компилятор на чём свет стоит, как это было и со мной в своё время. Чтобы понять, что скрыто за этой магией, и почему построитель запросов реализован именно так, придётся сделать небольшое лирическое отступление про for-включения и монады.

Монады

Что характерно, в книге Мартина Одерски «Programming in Scala» слово «монада» употребляется в одном-единственном месте — как раз в самом конце главы про for-включение, как бы между делом. Большая часть этой главы — описание того, как можно пользоваться синтаксической конструкцией for для итерации по коллекции, нескольким коллекциям, для фильтрации. И лишь в самом конце говорится о том, что есть такая штука как «монада», с которой тоже удобно работать с помощью for-включения, но подробного объяснения того, что это и зачем, не даётся. Между тем, использование for-включения для оперирования монадами является весьма эффектным и одновременно непонятным синтаксическим конструктом для взгляда новичка.

Не буду приводить здесь полноценный туториал по монадам, тем более, что их существует огромное количество, и их авторы объяснят тему лучше меня. Могу порекомендовать неплохое видео, объясняющее эту концепцию как раз на языке Scala. Для целей данной статьи будем считать, что монада — это параметризованный тип, нечто вроде функциональной обёртки, имеющей две основные операции с определёнными свойствами:

  • операция return — заворачивает (или «поднимает», «lifts») значение в некоторый контекст, представляемый этим типом;
  • операция bind — выполняет некоторую трансформирующую функцию над значением в этом контексте.

С точки зрения авторов языка Scala, в ООП операция return по сути реализуется конструктором экземпляра, принимающим значение (конструктор как раз позволяет «завернуть» переданное значение в объект), а операции bind соответствует метод flatMap. На самом деле монады в Scala — это не совсем монады в понимании классических функциональных языков типа Haskell, а, скорее, «монады по-одерски». И хотя в классических книгах по Scala избегают термина «монада», и даже в стандартной библиотеке вы с трудом найдёте упоминание этого слова, разработчики Slick не стесняются использовать его в документации и коде, полагая, что читателю уже известно, что это такое.

for-включения

На самом деле for-comprehension— это, конечно, не цикл, и ключевое слово for может поначалу сбить с толку. Кстати, я пытался разобраться, как же переводится на русский язык «for-comprehension» — варианты есть, а общепринятого нет. Некоторую полемику на эту тему можно почитать тут, тут и тут.

Я остановился на термине «for-включение», потому что оно обычно описывает включение элементов в выходное множество по определённым правилам. Хотя, если рассматривать for-comprehension как monadic comprehension, то такой перевод становится не столь очевиден. Ввиду небольшого количества литературы по ФП и теории категорий на русском языке, термин на текущий момент не устоялся.

Ирония в том, что, по мнению авторов Programming in Scala, одна из наилучших областей применения for-включения — это комбинаторные головоломки:

Как написать SQL-запрос на Slick и не открыть портал в ад - 2

Всё это замечательно и полезно, но как насчёт реальных кейсов применения?

Оказывается, мощь паттерна монады, особенно в сочетании с for-включением, заключается в том, что он позволяет выполнять высокоуровневую композицию отдельных действий в достаточно сложном контексте, иначе говоря, строить из маленьких кубиков (операций bind/flatMap) более сложные конструкции. Синтаксис for-включения даёт возможность выстраивать в последовательную цепочку такие действия, которые на самом деле нельзя выполнить последовательно. Обычно сложность их выполнения заключается в наличии какого-то сложного контекста. Например, одна из часто используемых монад в Scala — это List:

  // списки
  val people = List("Воронин", "Гейгер", "Убуката")
  val positions = List("мусорщик", "следователь", "редактор")

  // декартово произведение списков с использованием for-включения:
  val peoplePositions = for {
    person <- people
    position <- positions
  } yield s"$person, $position"

С помощью for-включения над отдельными экземплярами монады List можно выполнять декартово произведение, т.е. композицию списков. Монада при этом скрывает от нас сложность контекста (итерацию по множеству значений).

На деле же for-включение — это просто синтаксический сахар с строго определёнными правилами преобразования. В частности, все стрелочки, кроме последней, превращаются в вызовы flatMap у идентификаторов справа, а последняя стрелочка — в вызов map. Идентификаторы слева при этом трансформируются в аргументы функций для методов flatMap, а содержимое yield — это то, что возвращается из последней функции.

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

  // декартово произведение списков прямым вызовом flatMap и map:
  val peoplePositions2 = people.flatMap {person =>
    positions.map { position =>
      s"$person, $position"
    }
  }

Аналогично, монадическая реализация Future позволяет выстраивать действия над значениями в цепочки, скрывая от нас сложность контекста (асинхронность выполнения действий и тот факт, что вычисление значений отложено):

  // первая футура формирует и возвращает строку
  def getFuture1 = Future {
    "1337"
  }

  // вторая футура из строки делает число
  def getFuture2(string: String) = Future {
    string.toInt
  }

  // комбинированная футура, созданная с использованием for-включения
  val composedFuture = for {
    result1 <- getFuture1
    result2 <- getFuture2(result1)
  } yield result2

Если нам нужно передать в футуру параметр, это можно сделать с помощью замыкания, как показано выше: заворачиваем футуру в функцию с аргументом и используем аргумент внутри футуры. Благодаря этому можно будет связывать отдельные футуры друг с другом. Соответственно, «десахарированный» код будет выглядеть как множество вложенных вызовов flatMap, завершающихся вызовом map:

  // комбинированная футура, созданная с использованием flatMap и map
  val composedFuture2 = getFuture1.flatMap { string =>
    getFuture2(string).map { int =>
      int
    }
  }

for-включение, монады и построение запросов

Итак, операция flatMap является средством композиции монадических объектов, или построения сложных структур из простых кирпичиков. Что же касается языка SQL, то там тоже есть средство для композиции — это предложение JOIN. Если теперь вернуться к for-включению и его использованию для построения запросов, то становится очевидным, что flatMap и JOIN имеют много общего, и отображение одного на другое вполне осмысленно и разумно. Посмотрим ещё раз на пример построения запроса с внутренним джойном, который приводился в начале статьи. Теперь идея, заложенная в такой синтаксис, должна стать несколько понятнее:

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)

Но вот одна из шероховатостей такого подхода: в SQL есть ещё левые и правые джойны, и эти особенности на монадическое включение ложатся не очень хорошо: какие-либо синтаксические средства, позволяющие выразить подобные типы джойнов, в for-включении отсутствуют, и для левых и правых джойнов предлагается пользоваться альтернативным синтаксисом — аппликативными джойнами. В этом, кстати, заключается большая и серьёзная проблема многих подходов в Scala, когда сложные концепции моделируются средствами языка — любые средства языка имеют ограничения, в которые эта концепция рано или поздно упирается. Но об этой особенности Scala — как-нибудь в другой раз.

Мало того, в Slick монады используются аж на двух уровнях — в конструкторе запросов (как отдельные компоненты запроса, которые можно объединять) и при композиции действий с базой данных (их можно объединять в комплексные действия, которые затем завернуть в транзакцию). Честно говоря, это поначалу доставляло мне немало проблем, потому что с помощью for-включения можно объединять как монадические запросы, так и монадические действия, и я долго «намётывал глаз», пока не научился в коде отличать одну монаду от другой. Монадические действия — это как раз тема следующей главы…

Монады и композиция действий с базой данных

Довольно теории, приступим к хардкору. Попробуем написать на Slick что-нибудь более полезное, чем простой запрос. Начнём опять-таки с запроса с внутренним джойном:

  val monadicInnerJoin = for {
    ph <- phones
    pe <- persons if ph.personId === pe.id
  } yield (pe.name, ph.number)

Из атрибута result получившегося значения можно извлечь объект типа DBIOAction — ещё одну монаду, но уже предназначенную для композиции отдельных действий, выполняемых с базами данных.

// делаем из запроса DBIO-действие
val action1 = monadicInnerJoin.result

Любое действие, в том числе и композитное, можно выполнить в рамках транзакции:

val transactionalAction1 = action1.transactionally

Но как быть, если нам нужно завернуть в транзакцию несколько отдельных действий, некоторые из которых вообще не связаны с базой данных? В этом нам поможет метод DBIO.successful:

// делаем DBIO-действие из какой-то произвольной функции
val action2 = DBIO.successful {
  println("Делаем что-то между запросами в транзакции...")
}

Кстати, если завернуть создание action в функцию с аргументом, можно, как и в случае с футурами выше, параметризовать это действие, но мы не будем этого делать. Вместо этого просто добавим в микс ещё парочку DBIO-действий по вставке данных в таблицы и скомпонуем всё это в композитное действие с помощью for-включения:

// ещё парочка DBIO-действий...
val action3 = persons += (1, "Grace")
val action4 = phones += (1, 1, "+1 (800) FUC-KYOU")

// делаем композитное действие из всех четырёх действий
val compositeAction = for {
  result <- action1
  _ <- action2
  personCount <- action3
  phoneCount <- action4
} yield personCount + phoneCount

Обратите внимание — если результат действия нас не интересует (оно выполняется ради побочного эффекта), то слева от стрелочки можно поставить символ подчёркивания. Теперь завернём композитное действие в транзакцию и создадим на основе него футуру:

// заворачиваем композитное действие в транзакцию и делаем из него футуру
val actionFuture = db.run(compositeAction.transactionally)

Ну и наконец скомпонуем эту футуру с другой футурой с помощью всемогущего for и дождёмся её выполнения с помощью Await.result:

val databaseFuture = for {
  i <- actionFuture
  _ <- Future {
    println(s"Вставлено записей: $i")
  }
} yield ()

Await.result(databaseFuture, 1 second)

Вот так всё просто.

Заключение

Монады и синтаксис for-включения часто используются в различных Scala-библиотеках для построения больших конструкций из маленьких кирпичиков. В одном только Slick их можно использовать как минимум в трёх разных местах — для сборки таблиц в запрос, сборки действий в одно большое действие и сборки футур в одну большую футуру. Пониманию философии Slick и облегчению работы с ним очень способствует понимание того, как работает for-включение, что такое монады, и как for-включение облегчает работу с монадами.

Надеюсь, эта статья поможет новичкам в Scala и Slick не отчаяться и обуздать всю мощь этого фреймворка. Исходный код к статье доступен на GitHub.

Автор:

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js