Як написати SQL-запит на Slick і не відкрити портал в пекло



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-включення — це комбінаторні головоломки:



Все це чудово і корисно, але як щодо реальних кейсів застосування?

Виявляється, міць патерну монади, особливо в поєднанні з 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 { result1 =>
getFuture2(result1).map { result2 =>
result2
}
}

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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.