Persistent и работа с базами данных в Yesod

9 января 2013

Предлагаю вашему вниманию перевод еще одной главы из замечательной книги «Developing Web Applications with Haskell and Yesod». Эта глава, как и большинство других, будет интересна даже тем, кто не хочет ничего знать об этом нашем Yesod и вообще когда-либо писать на Haskell. Правда-правда! Если же вы пропустили переводы других глав или совсем не понимаете, о чем идет речь, попробуйте начать чтение с этого поста.

Внимание! Статья содержит очень много букв. Если вы перешли на эту страницу из Twitter или Google Reader в разгаре рабочего дня, советую добавить ее в закладки и прочитать позже.

Persistent

Формы представляют собой границу между пользователем и приложением. Другая граница, с которой нам приходится иметь дело, находится между приложением и хранилищем. Является ли это хранилище SQL базой данных, YAML- или бинарным файлом, вам с большой вероятностью придется попотеть, чтобы хранилище принимало типы данных вашего приложения. Persistent представляет собой ответ Yesod’а на проблему хранения данных. Это универсальный типобезопасный интерфейс к хранилищу данных для Haskell.

Haskell предлагает множество различных биндингов к базам данных. Однако большинство из них имеют малое представление о схеме базы данных и потому не обеспечивают полезных статических проверок. Кроме того, они вынуждают программиста использовать API и типы данных, зависящие от конкретной базы данных. Чтобы избавиться от этих проблем, программистами на Haskell была предпринята попытка пойти более революционным путем и создать хранилище данных, специфичное для Haskell, тем самым получив возможность с легкостью хранить любой тип данных Haskell. Эта возможность действительно прекрасна в некоторых случаях, но она делает программиста зависимым от техники хранения данных и используемой библиотеки, плохо взаимодействует с другими языками, а также для обеспечения гибкости может требовать от программиста написания кучи кода, запрашивающего данные. В отличие от Persistent, который предоставляет выбор среди множества баз данных, каждая из которых оптимизирована для различных случаев, позволяет взаимодействовать с другими языками, а также использовать безопасный и производительный интерфейс запросов.

Persistent следует принципам безопасности типов и краткого, декларативного синтаксиса. Среди других возможностей следует отметить:

  • Независимость от базы данных. Имеется первоклассная поддержка PostgreSQL, SQLite и MongoDB, а также экспериментальная поддержка CouchDB и находящаяся в разработке поддержка MySQL.
  • Будучи нереляционным по своей природе, Persistent позволяет одновременно поддерживать множество слоев хранения данных и не обременен проблемами производительности, связанными с использованием JOIN’ов.
  • Основным источником разочарования при использовании SQL баз данных является попытка изменения схемы базы данных. Persistent позволяет автоматически выполнять обновление схемы базы данных.

Решение пограничной проблемы

Допустим, вы храните информацию о людях в SQL базе данных. Соответствующая таблица может выглядеть как-то так:

CREATE TABLE Person(id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, age INTEGER)

И если вы используете такую СУБД, как PostgreSQL, вы можете быть уверены, что СУБД никогда не сохранит какой-нибудь дополнительный текст в поле age. (Нельзя сказать то же самое в отношении SQLite, однако пока что забудем об этом.) Для отображения этой таблицы вы можете захотеть создать примерно такой тип данных:

data Person = Person
    { personName :: Text
    , personAge :: Int
    }

Все выглядит вполне типобезопасно — схема базы данных соответствует типу данных в Haskell, СУБД гарантирует, что некорректные данные никогда не будут сохранены в таблице, и все в целом выглядит прекрасно. До поры до времени.

Вы хотите получить данные из СУБД, которая в свою очередь предоставляет их в нетипизированном формате.

  • Вы хотите найти все людей, старше 32-х лет, но по ошибке пишете «тридцать два» в SQL-запросе. И знаете что? Все прекрасно скомпилируется и вы не узнаете о проблеме до тех пор, пока не запустите программу.
  • Вы решили найти первых десятерых человек в алфавитном порядке. Нет проблем… до тех пор, пока вы не сделаете опечатку в SQL-запросе. И снова, вы не узнаете об этом до тех пор, пока не запустите программу.
  • В языках с динамической типизацией ответом на эти проблемы является модульное тестирование. Проверьте, что для всего, что может пойти не так, вы не забили написать тест. Но как, я полагаю, вы уже знаете, это не очень согласуется с подходом, принятом в Yesod. Мы предпочитаем использовать преимущества статической типизации языка Haskell для нашей собственной защиты, насколько это возможно, и хранение данных не является исключением.

Итак, вопрос остается открытым: как мы можем использовать систему типов языка Haskell, чтобы исправить положение?

Типы

Как и в случае с маршрутами, нет ничего невероятно сложного в типобезопасном доступе к данным. Он всего лишь требует написания монотонного, подверженного ошибкам избыточного шаблонного кода. Как обычно, это означает, что мы можем использовать систему типов для того, чтобы избежать лишних ошибок. А чтобы не заниматься нудной работой, мы вооружимся Template Haskell.

Примечание: В ранних версиях Persistent очень активно использовался Template Haskell. Начиная с версии 0.6 используется новая архитектура, позаимствованная из пакета groundhog. Благодаря новому подходу существенная часть нагрузки была переложена на фантомные типы.

PersistValue является основным строительным блоком в Persistent. Этот тип представляет данные, посылаемые базе данных или получаемые от нее. Вот его определение:

data PersistValue = PersistText Text
                  | PersistByteString ByteString
                  | PersistInt64 Int64
                  | PersistDouble Double
                  | PersistBool Bool
                  | PersistDay Day
                  | PersistTimeOfDay TimeOfDay
                  | PersistUTCTime UTCTime
                  | PersistNull
                  | PersistList [PersistValue]
                  | PersistMap [(T.Text, PersistValue)]
                  | PersistForeignKey ByteString -- ^ предназначен прежде всего для MongoDB

Каждый из бэкэндов Persistent должен знать, как переводить соответствующие значения во что-то, понятное СУБД. Однако было бы неудобно выражать все данные через эти базовые типы. Следующим слоем является класс типов PersistField, определяющий, как произвольный тип может быть преобразован в тип PersistValue или обратно. PersistField соответствует столбцами в SQL базах данных. В приведенном ранее примере с людьми name и age будут нашими PersistField’ами.

Чтобы связать пользовательский код, нам понадобится последний класс типов — PersistEntity. Экземпляр класса типов PersistEntity соответствует таблице в SQL базе данных. Этот класс типов определяет несколько функций и связанные с ними типы. Таким образом, имеет место следующее соответствие между Persistent и SQL:

SQL Persistent
Тип (VARCHAR, INTEGER и тд) PersistValue
Столбец PersistField
Таблица PersistEntity

Генерация кода

Дабы убедиться, что экземпляры класса PersistEntity корректно соответствуют нашим типам данных, Persistent берет на себя ответственность и за тех, и за других. Это хорошо и с точки зрения принципа DRY (Не повторяйтесь, Don’t Repeat Yourslef): от вас требуется объявить сущности только один раз. Рассмотрим следующий пример:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs #-}

import Database.Persist
import Database.Persist.TH
import Database.Persist.Sqlite
import Control.Monad.IO.Class (liftIO)

mkPersist sqlSettings [persist|
Person
    name String
    age Int
    deriving Show
|]
main = return ()

Здесь мы используем комбинацию из Template Haskell и квазицитирования (как при определении маршрутов): persist является обработчиком квазицитирования, который преобразует чувствительный к пробелам синтаксис в список определений сущностей. (Также вы можете вынести определение сущностей в отдельный файл и воспользоваться persistFile.) mkPersist принимает список этих сущностей и определяет:

  • По одному типу данных языка Haskell на сущность;
  • Экземпляр класса PersistEntity для каждого определенного типа данных;

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

{-# LANGUAGE TypeFamilies, GeneralizedNewtypeDeriving, OverloadedStrings, GADTs #-}

import Database.Persist
import Database.Persist.Store
import Database.Persist.Sqlite
import Database.Persist.EntityDef
import Control.Monad.IO.Class (liftIO)
import Control.Applicative

data Person = Person
    { personName :: String
    , personAge :: Int
    }
  deriving (Show, Read, Eq)

type PersonId = Key SqlPersist Person

instance PersistEntity Person where
    -- Обобщенный алгебраический тип данных.
    -- Это дает нам типобезопасный подход к сопоставлению
    -- полей с их типами данных
    data EntityField Person typ where
        PersonId   :: EntityField Person PersonId
        PersonName :: EntityField Person String
        PersonAge  :: EntityField Person Int

    type PersistEntityBackend Person = SqlPersist

    toPersistFields (Person name age) =
        [ SomePersistField name
        , SomePersistField age
        ]

    fromPersistValues [nameValue, ageValue] = Person
        <$> fromPersistValue nameValue
        <*> fromPersistValue ageValue
    fromPersistValues _ = Left "Invalid fromPersistValues input"

    -- Информация о каждом поле для внутреннего использования
    -- при генерации SQL-выражений
    persistFieldDef PersonId = FieldDef
        (HaskellName "Id")
        (DBName "id")
        (FTTypeCon Nothing "PersonId")
        []
    persistFieldDef PersonName = FieldDef
        (HaskellName "name")
        (DBName "name")
        (FTTypeCon Nothing "String")
        []
    persistFieldDef PersonAge = FieldDef
        (HaskellName "age")
        (DBName "age")
        (FTTypeCon Nothing "Int")
        []
    data Unique Person typ = IgnoreThis
    entityDef = undefined
    halfDefined = undefined
    persistUniqueToFieldNames = undefined
    persistUniqueToValues = undefined
    persistUniqueKeys = undefined
    persistIdField = undefined
main :: IO ()
main = return ()

Как и следовало ожидать, определение типа данных Person очень близко к определению, данному в оригинальной версии кода, где использовался Template Haskell. Мы также имеем обобщенный алгебраический тип данный (ОАТД), предоставляющий отдельный конструктор для каждого поля. Этот ОАТД кодирует как тип сущности, так и тип поля. Мы используем его конструкторы через модуль Persistent, например, чтобы убедиться, что когда мы применяем фильтр, типы фильтруемого значения и поля совпадают.

Мы можем использовать сгенерированный тип Person как и любой другой тип языка Haskell, а затем передать его в одну из функций модуля Persistent.

main = withSqliteConn ":memory:" $ runSqlConn $ do
    michaelId <- insert $ Person "Michael" 26
    michael <- get michaelId
    liftIO $ print michael

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

В приведенном примере мы видим две функции. Функция insert создает новую запись в базе данных и возвращает ее ID. Как и все остальное в модуле Persistent, ID являются типобезопасными. Более подробно о том, как работают эти ID, мы узнаем позже. Итак, код insert $ Person "Michael" 25, возвращает значение типа PersonId.

Следующая функция, которую мы видим — это get. Она пытается загрузить из базы данных значение, используя заданный ID. При использовании Persistent вам никогда не придется беспокоиться, что вы, возможно, используете ключ не от той таблицы. Код, который пытается получить другую сущность (например, House), используя PersonId, никогда не будет скомпилирован.

PersistStore

Последний момент, который остался без объяснения в предыдущем примере: что делают функции withSqliteConn и runSqlConn? И что это за монада, в которой выполняются все наши действия с базой данных?

Все действия с базой данных должны выполняться в экземпляре PersistStore. Как следует из его названия, каждое хранилище (PostgreSQL, SQLite, MongoDB) имеет свой экземпляр PersistStore. Именно с его помощью происходят генерация SQL-запросов, преобразования из PersistValue в значения, специфичные для конкретной СУБД и так далее.

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

Функция withSqliteConn создает отдельное соединение с базой данных, используя предоставленную строку. Для тестов мы воспользуемся строкой «:memory:», которая означает использовать базу данных, расположенную в памяти. Функция runSqlConn использует это соединение для выполнения действий над базой данных. SQLite и PostgreSQL используют один и тот же экземпляр PersistStore: SqlPersist.

Примечание: В действительности существует еще несколько классов типов — это PersistUpdate и PersistQuery. Различные классы типов предоставляют различную функциональность, что позволяет нам писать бэкенды, использующие более простые хранилища (например, Redis) несмотря на то, что они не обладают всей высокоуровневой функциональностью, предоставляемой Persistent.

Важный момент, который следует отметить, заключается в том, что каждый отдельный вызов runSqlConn выполняется в отдельной транзакции. Отсюда два следствия:

  • Для многих СУБД выполнение коммита может быть дорогой операцией. Помещая множество запросов в одну транзакцию, вы можете существенно ускорить выполнение кода.
  • Если где-либо внутри вызова runSqlConn бросается исключение, все выполненные действия будут откачены (конечно, если используемый бэкенд поддерживает транзакции).

Миграции

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

При работе с реляционными СУБД, изменение схемы базы данных обычно является большой проблемой. Вместо того, чтобы возлагать эту проблему на плечи пользователя, Persistent делает шаг вперед и протягивает руку помощи. Только нужно его об этом попросить. Вот как примерно это выглядит:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.TH
import Database.Persist.Sqlite
import Control.Monad.IO.Class (liftIO)

share [mkPersist sqlSettings, mkSave "entityDefs"] [persist|
Person
    name String
    age Int
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration $ migrate entityDefs (undefined :: Person) -- добавлена эта строчка, и только!
    michaelId <- insert $ Person "Michael" 26
    michael <- get michaelId
    liftIO $ print michael

Благодаря этому небольшому изменению, Persistent будет автоматически создавать для вас таблицу Person. Разбиение между функциями runMigration и migrate позволяет производить миграции множества таблиц одновременно.

Это хорошо работает, когда речь идет о нескольких сущностях, но становится несколько утомительным при работе с десятками. Вместо того, чтобы повторяться, Persistent предлагает функцию mkMigrate:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    age Int
    deriving Show
Car
    color String
    make String
    model String
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
       runMigration migrateAll

mkMigrate — это функция Template Haskell, которая создает новую функцию, что будет вызывать migrate для всех сущностей, объявленных в блоке persist. Функция share является небольшим хелпером, который передает информацию из блока persist каждой из функций Template Haskell, а затем объединяет результаты их выполнения.

В Persistent используются очень консервативные правила относительно того, что следует делать во время миграции. Сначала он загружает из базы данных всю информацию о таблицах, вместе со всеми объявленными типами данных SQL. Эту информацию он сравнивает с определениями сущностей, приведенными в коде. В следующих случаях схема базы данных будет изменена автоматически:

  • Изменился тип данных поля. Но СУБД может возражать против такого изменения, если данные не могут быть преобразованы.
  • Было добавлено новое поле. Однако если поле не может быть пустым (NULL), не было предоставлено значение по умолчанию (как это сделать, мы обсудим позже), и в таблице уже есть какие-то данные, СУБД не позволит добавить поле.
  • Некоторое поле отныне может быть пустым. В обратном случае Persistent попытается выполнить преобразование, если СУБД позволит это сделать.
  • Была добавлена совершенно новая сущность.

Однако есть и случаи, которые Persistent не в состоянии обработать:

  • Переименование сущностей или полей. У Persistent нет никакой возможности узнать, что поле «name» было переименовано в «fullName». Все, что он видит — это старое поле с именем «name» и новое поле с именем «fullName».
  • Удаление полей. Поскольку это может привести к потере данных, по умолчанию Persistent отказывается выполнять такие преобразования. Вы можете настоять на этом, воспользовавшись функцией runMigrationUnsafe вместо runMigration, но это не рекомендуется.

Функция runMigration выводит выполняемые миграции в STDERR (если вам не нравится такое поведение, воспользуйтесь функцией runMigrationSilent). По возможности она использует запросы ALTER TABLE. Однако в SQLite ALTER TABLE имеет очень малые возможности, поэтому Persistent приходится прибегнуть к копированию данных из одной таблицы в другую.

Наконец, если вы хотите, чтобы вместо выполнения миграций Persistent дал вам подсказку по самостоятельному выполнению этих миграцией, воспользуйтесь функцией printMigration. Эта функция выводит действия, которые были бы выполнены функцией runMigration. Это может быть полезно в случае выполнения миграций, на который Persistent не способен, выполнения дополнительных SQL-запросов во время миграций, или же просто для логирования происходящих миграций.

Уникальность

Помимо объявления полей у сущности мы также может объявлять ограничение уникальности. Типичный пример — это требование уникальности имени пользователя:

User
    username Text
    UniqueUsername username

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

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}
import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Data.Time
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Resource (runResourceT)

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    firstName String
    lastName String
    age Int
    UniqueName firstName lastName
    deriving Show
|]

main = runResourceT $ withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll
    insert $ Person "Michael" "Snoyman" 26
    michael <- getBy $ UniqueName "Michael" "Snoyman"
    liftIO $ print michael

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

Главное ограничение, связанное с уникальностью, состоит в том, что она может использоваться только для непустых (non-null) полей. Причина заключается в том, что стандарт SQL неоднозначен относительно уникальности пустых полей (например, NULL=NULL является истиной или ложью?). К тому же, в большинстве СУБД реализованы правила, которые не соответствуют правилам для соответствующих типов данных в Haskell (например, в PostgreSQL NULL=NULL — это ложь, а в Haskell Nothing=Nothing есть True).

В дополнение к предоставлению гарантий на уровне СУБД относительно согласованности данных, ограничение уникальности также может быть использовано для выполнения некоторых специфических запросов из кода на Haskell, как, например, в случае с getBy, продемонстрированом выше. Здесь используется ассоциативный тип Unique. В конце приведенного выше примера используется следующий конструктор:

UniqueName :: String -> String -> Unique Person

Примечание: В случае использования MongoDB ограничение уникальности не может быть использовано — вы должны создать уникальный индекс по полю.

Запросы

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

В связи с этим Persistent предоставляет множество различных функций для выполнения запросов. Как обычно, мы стараемся закодировать с помощью типов столько инвариантов, сколько возможно. Например, если запрос может возвращать либо 0, либо 1 результат, используется обертка Maybe. Если же запрос может вернуть много результатов, возвращается список.

Выборка по ID

Простейший запрос, который может быть выполнен в Persistent — это выборка по ID. Поскольку в этом случае значение может существовать или не существовать, возвращаемое значение оборачивается в Maybe.

Использование функции get:

    personId <- insert $ Person "Michael" "Snoyman" 26
    maybePerson <- get personId
    case maybePerson of
        Nothing -> liftIO $ putStrLn "Ничего нет"
        Just person -> liftIO $ print person

Это может быть очень удобно на сайтах, предоставляющих URL типа /person/5. Однако в таких случаях мы обычно не беспокоимся о Maybe, а просто хотим получить значение или вернуть код 404, если оно не найдено. К счастью, есть функция get404, которая поможет нам в этом. Мы разберемся с этим вопросом более детально, когда дойдем до интеграции с Yesod.

Выборка по уникальному ключу

Функция getBy почти идентична get, только вместо ID она принимает значение Unique.

Использование функции getBy:

    personId <- insert $ Person "Michael" "Snoyman" 26
    maybePerson <- getBy $ UniqueName "Michael" "Snoyman"
    case maybePerson of
        Nothing -> liftIO $ putStrLn "Ничего нет"
        Just person -> liftIO $ print person

Аналогично get404, также существует функция getBy404.

Другие функции выборки

Скорее всего, вам хотелось бы выполнять более сложные запросы, например, найти всех людей определенного возраста, все свободные машины синего цвета, всех пользователей без указанного email и тд. Для этого вам понадобится одна из следующий функций выборки.

Все эти функции имеют похожий интерфейс и немного различающиеся возвращаемые значения:

  • selectSource. Возвращает источник (source), содержащий все ID и значения из базы данных. Это позволяет писать поточный код. (*)
  • selectList. Возвращает список, содержащий все ID и значения из базы данных. Все записи будут помещены в память.
  • selectFirst. Просто возвращает первый ID и первое значение из базы данных, если они есть.
  • selectKeys. Возвращает только ключи, без значений, в качестве источника.

Примечание: (*) Мы более подробно рассмотрим источники в приложении, посвященном кондуитам (conduits). Кроме того, есть и другая функция под названием selectSourceConn, которая предоставляет больше контроля над выделением соединений. Мы рассмотрим ее в главе, посвященной работе со Sphinx.

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

Функция selectList принимает два аргумента: список Filter’ов и список SelectOpt’ов. Первый из них определяет ограничения, накладываемые на свойства сущностей, и позволяет использовать предикаты «равно», «меньше чем», «принадлежит множеству» и тп. SelectOpt’ы предоставляют три различных возможности — сортировку, ограничение количества возвращаемых строк и смещение возвращаемого значения на заданное количество строк.

Примечание: Комбинация из ограничения и смещения очень важна, она позволяет реализовать эффективное разбиение на страницы в вашем веб-приложении.

Сразу перейдем к примеру с фильтрацией, а затем проанализируем его:

    people <- selectList [PersonAge >. 25, PersonAge <=. 30] []
    liftIO $ print people

Несмотря на простоту примера, необходимо отметить три момента:

  • PersonAge является конструктором ассоциативного фантомного типа. Звучит ужасающе, однако действительно важно лишь то, что он однозначно определяет столбец «age» таблицы «person», а также знает, что возраст на самом деле является Int’ом. (В этом и состоит его фантомность.)
  • Мы имеем дело с группой фильтрующих операторов пакета Persistent. Они довольно прямолинейны и делают в точности то, что вы от них ожидаете. Однако тут есть три тонких момента, которые я объясню ниже.
  • Список фильтров объединяется логическим И, то есть, ограничение следует читать, как «возраст больше 25-и И возраст меньше или равен 30-и». Использование логического ИЛИ мы рассмотрим ниже.

Также имеется оператор с удивительным названием «не равно». Мы используем обозначение !=.', поскольку /=. используется при UPDATE-запросах (ради «разделяй-и-устанавливай», о котором я расскажу позже). Не беспокойтесь, если вы воспользуетесь неверным оператором, компилятор предупредит вас. Еще два удивительных оператора — это «принадлежит множеству» и «не принадлежит множеству». Они обозначаются, соответственно, <-. и /<-. (оба с точкой на конце).

Что же касается логического ИЛИ, для него есть оператор ||.. Например:

    people <- selectList
        (       [PersonAge >. 25, PersonAge <=. 30]
            ||. [PersonFirstName /<-. ["Adam", "Bonny"]]
            ||. ([PersonAge ==. 50] ||. [PersonAge ==. 60])
        )
        []
    liftIO $ print people

Этот (совершенно нелепый) пример означает «найти людей, чей возраст составляет от 26-и до 30-и лет включительно ИЛИ чье имя не Адам и не Бонни ИЛИ чей возраст — 50 или 60 лет».

SelectOpt

Все наши вызовы selectList имели пустой список в качестве второго аргумента. Это не задает никаких параметров и означает «сортируй на усмотрение СУБД, возвращай все результаты, не пропускай никаких результатов». SelectOpt имеет четыре конструктора, которые могут быть использованы для изменения этого поведения:

  • Asc. Сортировать по заданному столбцу в неубывающем порядке. Тут используется такой же фантомный тип, как и при фильтрации, например PersonAge.
  • Desc. Аналогично Asc, только в невозрастающем порядке.
  • LimitTo. Принимает аргумент типа Int. Вернуть не более указанного количества результатов.
  • OffsetBy. Также принимает аргумент типа Int. Пропустить указанное количество результатов.

В следующем отрывке кода определяется функция, которая разбивает результат на страницы. Она возвращает всех людей старше 18-и лет, отсортированных по возрасту (более старшие идут первыми). Люди с одинаковым возрастом сортируются по фамилиям, а затем по именам.

resultsForPage pageNumber = do
    let resultsPerPage = 10
    selectList
        [ PersonAge >=. 18
        ]
        [ Desc PersonAge
        , Asc PersonLastName
        , Asc PersonFirstName
        , LimitTo resultsPerPage
        , OffsetBy $ (pageNumber - 1) * resultsPerPage
        ]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigrationSilent migrateAll
    personId <- insert $ Person "Michael" "Snoyman" 26
    resultsForPage 1 >>= liftIO . print

Другие действия с данными

Извлечение данных — это только полдела. Нам также необходимо иметь возможность добавлять данные и модифицировать данные, находящиеся в базе.

Вставка

Иметь возможность работать с данными из базы — это здорово и замечательно, но как эти данные туда попадут? Для этого есть функция insert. Вы просто передаете ей значение, а она возвращает ID.

В связи с этим имеет смысл немного пояснить философию Persistent. Во многих ORM типы, используемые для работы с данными, непрозрачны. Вам приходится продираться через определяемый ими интерфейс, чтобы получить, а затем изменить данные. Однако в Persistent все иначе — для всего используются старые добрые алгебраические типы данных. Таким образом, вы по-прежнему можете иметь огромный выигрыш от использования сопоставления с образцом, каррирования и всего остального, к чему вы привыкли.

Однако есть вещи, которые мы не можем делать. Например, нет способа автоматически обновлять данные в базе данных при каждом их изменении в Haskell. Конечно, учитывая позицию языка Haskell в отношении чистоты и неизменяемости, от этого все равно было бы мало проку, так что не будем лить слезы.

Тем не менее, есть момент, который часто беспокоит новичков. Почему ID и значения совершенно разделены? Казалось бы, куда логичнее было бы включить ID в само значение. Другими словами, вместо:

data Person = Person { name :: String }

… мы имели бы:

data Person = Person { personId :: PersonId, name :: String }

Одна из проблем сразу бросается в глаза. Как прикажете производить вставку? Если Person требуется ID, а ID возвращается функцией insert, которой в свою очередь требуется Person, мы получаем проблему курицы и яйца. Мы могли бы решить эту проблему, используя неопределенный ID, однако это верный способ нарваться на неприятности.

Вы скажете, хорошо, давайте попробуем что-то более безопасное:

data Person = Person { personId :: Maybe PersonId, name :: String }

Намного предпочтительнее писать insert $ Person Nothing "Michael" вместо insert $ Person undefined "Michael". И наши типы стали намного проще, не так ли? Например, selectList теперь может возвращать просто [Person] вместо уродливого [Entity SqlPersist Person].

Примечание: Entity представляет собой тип данных, который связывает ID и значение сущности воедино. Поскольку ID могут быть разными в зависимости от бэкенда, необходимо также предоставить используемый бэкенд пакета Persistent. Тип данных Entity SqlPersist Person следует читать как «ID и значение некого человека, хранящиеся в SQL базе данных».

Проблема заключается в том, что «уродство» оказывается невероятно полезным. Запись Entity SqlPersist Person делает очевидным тот факт, что мы работаем со значением, которое существует в базе данных. Допустим, мы хотим создать URL, в котором присутствует PersonId (не такой уж редкий случай, как мы вскоре выясним). Entity SqlPersist Person недвусмысленно предоставляет доступ к требуемой информации. Тем временем, использование обертки Maybe приводит к потребности в дополнительных проверках во время выполнения, вместо того, чтобы убедиться в корректности программы еще на этапе компиляции.

Наконец, в случае присоединения ID к значению имеет место семантическое несоответствие. Person — это значение. Два человека являются идентичными (с точки зрения базы данных), если все их поля одинаковы. Присоединяя ID к значению, мы начинаем говорить не о человеке, а о строке в базе данных. Равенство перестает быть равенством, оно превращается в идентичность: «это тот же самый человек» вместо «это такой же человек».

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

Обновление

Теперь подумаем об обновлении в контексте нашего обсуждения. Вот простейший способ сделать обновление:

let michael = Person "Michael" 26
    michaelAfterBirthday = michael { personAge = 27 }

Однако в действительности этот код ничего не обновляет. Он просто создает новое значение типа Person, основанное на старом значении. Когда мы говорим об обновлении, мы имеем в виду не модификацию значений в Haskell. (И вправду, не стоило бы этого делать, поскольку данные в Haskell неизменяемы.)

На самом деле, мы ищем способ изменить строки в таблице. И проще всего сделать это с помощью функции update:

    personId <- insert $ Person "Michael" "Snoyman" 26
    update personId [PersonAge =. 27]
    resultsForPage 1 >>= liftIO . print

Функция update принимает два аргумента: ID и список Update’ов. Простейший способ обновления поля заключается в присвоении ему нового значения, однако этот способ не лучший. Что, если вы хотите увеличить чей-то возраст на единицу, но текущий возраст вам не известен? В Persistent предусмотрено и это:

haveBirthday personId = update personId [PersonAge +=. 1]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigrationSilent migrateAll
    personId <- insert $ Person "Michael" "Snoyman" 26
    update personId [PersonAge =. 27]
    haveBirthday personId
    resultsForPage 1 >>= liftIO . print

Как и следовало ожидать, в нашем распоряжении есть все базовые математические операторы: +=., -=., *=. и /=.. Они не только удобны для обновления единичной записи, но и необходимы для соблюдения гарантий ACID. Представьте, что бы мы делали без этих операторов. Нам приходилось бы извлекать из базы Person, увеличивать возраст, а затем обновлять значение в базе данных на новое. Как только у вас появляется два процесса или потока, одновременно работающих с базой данных, вы попадаете в мир боли (подсказка: состояние гонки).

Иногда мы хотим обновить несколько строк одновременно (например, повысить зарплату на 5% всем сотрудникам). Функция updateWhere принимает два аргумента: список фильтров и список обновлений, которые следует применить.

    updateWhere [PersonFirstName ==. "Michael"] [PersonAge *=. 2] -- это был длинный день

Иногда хочется просто заменить одно значение в базе данных на другое. Для этого (сюрприз!) есть функция replace:

    personId <- insert $ Person "Michael" "Snoyman" 26
    replace personId $ Person "John" "Doe" 20
    update personId [PersonAge =. 27]
    haveBirthday personId
    updateWhere [PersonFirstName ==. "Michael"] [PersonAge *=. 2] -- это был длинный день
    resultsForPage 1 >>= liftIO . print

Удаление

Как ни печально, иногда мы вынуждены расстаться с нашими данными. Для этого у нас есть аж три функции:

Функция Действие
delete Удалить по ID
deleteBy Удалить по уникальному ключу
deleteWhere Удалить по множеству фильтров

    personId <- insert $ Person "Michael" "Snoyman" 26
    delete personId
    deleteBy $ UniqueName "Michael" "Snoyman"
    deleteWhere [PersonFirstName ==. "Michael"]

С помощью deleteWhere мы можем удалить вообще все данные из таблицы. Нужно только подсказать GHC, какая таблица нас интересует:

    deleteWhere ([] :: [Filter Person])

Атрибуты

До сих пор мы видели базовый синтаксис для наших persist-блоков — строка с именем сущности, за которой для каждого поля идет по одной строке с отступами, состоящей из двух слов, имени поля и типа данных поля. Persistent поддерживает не только это. После двух слов в строке вы можете указать произвольный список атрибутов.

Допустим, вы хотите, чтобы сущность Person имела необязательный возраст, а также время добавления его или ее в систему. Для сущностей, уже находящихся в базе данных, в качестве этого времени мы хотим использовать текущее время.

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Data.Time
import Control.Monad.IO.Class

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    age Int Maybe
    created UTCTime default=CURRENT_TIME
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    time <- liftIO getCurrentTime
    runMigration migrateAll
    insert $ Person "Michael" (Just 26) time
    insert $ Person "Greg" Nothing time

Maybe является встроенным атрибутом из одного слова. Он делает поле необязательным. Это означает, что в Haskell данное поле будет обернуто в Maybe, а в SQL оно может иметь значение NULL.

Атрибут default зависит от используемого бэкенда и может использовать любой синтаксис, лишь бы он был понятен СУБД. В приведенном примере используется встроенная функция СУБД CURRENT_TIME. Допустим, теперь мы хотим добавить в сущность Person поле с любимым языком программирования:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Data.Time

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    age Int Maybe
    created UTCTime default=CURRENT_TIME
    language String default='Haskell'
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll

Примечание: Атрибут default абсолютно никак не затрагивает код на Haskell, то есть, вам по-прежнему придется заполнять все значения. Атрибут влияет только на схему базы данных и автоматические миграции.

Мы должны окружить строку в одинарные кавычки, чтобы СУБД могла правильно интерпретировать ее. Также Persistent позволяет использовать двойные кавычки для строк, содержащих пробелы. Например, если мы хотим сделать страной по умолчанию Российскую Федерацию, то должны написать:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Data.Time

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    age Int Maybe
    created UTCTime default=CURRENT_TIME
    language String default='Haskell'
    country String "default='Российская Федерация'"
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll

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

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person sql=the-person-table
    firstName String sql=first_name
    lastName String sql=fldLastName
    age Int Gt Desc "sql=The Age of the Person"
    UniqueName firstName lastName
    deriving Show
|]

resultsForPage pageNumber = do
    let resultsPerPage = 10
    selectList
        [ PersonAge >=. 18
        ]
        [ Desc PersonAge
        , Asc PersonLastName
        , Asc PersonFirstName
        , LimitTo resultsPerPage
        , OffsetBy $ (pageNumber - 1) * resultsPerPage
        ]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll
    personId <- insert $ Person "Michael" "Snoyman" 26
    resultsForPage 1 >>= liftIO . print

Отношения

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

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Control.Monad.IO.Class (liftIO)
import Data.Time

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    deriving Show
Car
    ownerId PersonId Eq
    name String
    deriving Show
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll
    bruce <- insert $ Person "Bruce Wayne"
    insert $ Car bruce "Bat Mobile"
    insert $ Car bruce "Porsche"
    -- это может занять много времени
    cars <- selectList [CarOwnerId ==. bruce] []
    liftIO $ print cars

С помощью этой техники вы можете определять отношения один-ко-многим. Чтобы определить отношение многие-ко-многим, нам понадобится связующая сущность, которая имеет отношения один-ко-многим с каждой из оригинальных таблиц. В данном случае будет хорошей идеей воспользоваться ограничением уникальности. Допустим, мы хотим смоделировать ситуацию, в которой мы отслеживаем, какие люди в каких магазинах делают покупки:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH
import Data.Time

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
Store
    name String
PersonStore
    personId PersonId
    storeId StoreId
    UniquePersonStore personId storeId
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll

    bruce <- insert $ Person "Bruce Wayne"
    michael <- insert $ Person "Michael"

    target <- insert $ Store "Target"
    gucci <- insert $ Store "Gucci"
    sevenEleven <- insert $ Store "7-11"

    insert $ PersonStore bruce gucci
    insert $ PersonStore bruce sevenEleven

    insert $ PersonStore michael target
    insert $ PersonStore michael sevenEleven

Примечание: Поскольку суффикс Id в имени типа используется в Persistent для обозначения связи по внешнему ключу, в настоящий момент не представляется возможным использовать неключевые типы, чье имя заканчивается на Id. Простое решение этой проблемы заключается в определении синонима типа с другим суффиксом, например:

data MyExistingTypeEndingInId = ...
type IdIsNotTheSuffix = MyExistingTypeEndingInId
[persist|
Person
    someField IdIsNotTheSuffix

Подробнее о типах

До сих пор мы говорили о Person и PersonId без особого объяснения, чем они на самом деле являются. В простейшем случае, если мы говорим только о реляционных базах данных, PersinId мог бы быть просто type PersonId = Int64. Но в этом случае на уровне типов ничто не связывало бы PersinId и сущность Person. В результате вы могли бы по ошибке воспользоваться PersonId для получения Car. Для моделирования таких отношений мы используем фантомные типы. Итак, наш следующий наивный шаг был бы следующим:

newtype Key entity = Key Int64
type PersonId = Key Person

Примечание: До Persistent 0.6 мы использовали ассоциативные типы вместо фантомных типов. Таким способом также можно решить проблему, но фантомы справляются с ней лучше.

И это действительно отлично работает до тех пор, пока вы не столкнетесь с бэкендом, который не использует Int64 в качестве ID. И это далеко не теоретический вопрос, поскольку MongoDB для этих целей использует тип ByteString. Итак, нам нужно значение ключа, которое может содержать либо Int64, либо ByteString. Кажется, настало отличное время для применения суммарных типов:

data Key entity = KeyInt Int64 | KeyByteString ByteString

Но на самом деле мы только ищем неприятности. В следующий раз нам попадется бэкенд, который использует в качестве ключа временные метки и нам придется ввести дополнительный конструктор для Key. Так может продолжаться какое-то время. К счастью, у нас уже есть суммарный тип, предназначенный для представления произвольных данных, PersistValue:

newtype Key entity = Key PersistValue

Но тут есть другая проблема. Скажем, у нас есть веб-приложение, которое принимает ID в качестве параметра от пользователя. Этому приложению придется принимать параметр, как Text, а затем пытаться преобразовать его в Key. Нет проблем, давайте напишем функцию, которая преобразовывает Text в PersistValue, а затем передадим возвращаемое ее значение в конструктор Key. Правильно?

Нет, неправильно. Мы пробовали, и это оказалось большим геморроем. Все закончилось тем, что нам пришлось принимать ключи, которых не может быть. Например, если мы имеем дело с SQL, ключ должен быть целым числом. Но при подходе, описанном выше, в качестве ключа мы вынуждены принимать произвольные текстовые данные. В результате мы получали 500-ые ошибки, потому что СУБД была в шоке от попыток сравнивать целочисленные поля с текстом.

Что нам действительно нужно, это способ преобразования текста в Key, но с учетом правил используемого бэкенда. И как только вопрос становится сформулирован таким образом, мы тут же получаем ответ — добавить еще одного фантома. В действительности, определение Key в Persistent следующее:

newtype Key backend entity = Key { unKey :: PersistValue }

И это прекрасно работает. Теперь мы можем получить функцию Text -> Key MongoDB entity и функцию Text -> Key SqlPersist entity, после чего все работает, как по маслу. Но теперь есть другая проблема — отношения. Скажем, мы хотим представлять блоги и посты в блогах. Будем использовать следующее определение сущностей:

Blog
    title Text
Post
    title Text
    blogId BlogId

Но как это будет выглядеть с точки зрения типа данных Key?

data Blog = Blog { blogTitle :: Text }
data Post = Post { postTitle :: Text, postBlogId :: Key <что должно быть здесь?> Blog }

Мы должны указать какой-то бэкенд. В теории, мы можем захардкодить SqlPersist или Mongo, но тогда наши типы данных будут работать только с одним бэкендом. Для одного приложения это может быть приемлемым, но как насчет библиотек с определениями типов данных, которые могут использоваться в различных приложениях с различными бэкендами?

Так что, все становится чуть сложнее. На самом деле, наши типы такие:

data BlogGeneric backend = Blog { blogTitle :: Text }
data PostGeneric backend = Post { postTitle :: Text, postBlogId :: Key backend (BlogGeneric backend) }

Обратите внимание, что мы все еще используем короткие имена для конструкторов и записей. Наконец, чтобы предоставить простой интерфейс для нормального кода, мы определяем кое-какие синонимы типов:

type Blog = BlogGeneric SqlPersist
type BlogId = Key SqlPersist Blog
type Post = PostGeneric SqlPersist
type PostId = Key SqlPersist Post

И нет, SqlPersist не захардкожен где бы то ни было в Persistent. Это параметр sqlSettings, что вы передали в mkPersist, говорит нам использовать SqlPersist. В коде, использующем MongoDB, вместо него будет использован параметр mongoSettings.

Описанное выше может показаться несколько сложным, но при написании пользовательского кода все это едва ли когда-нибудь вам понадобится. Просмотрите еще раз эту главу — нам ни разу не приходилось работать с Key или Generic напрямую. Единственное место, где они могут всплыть, это сообщения компилятора об ошибках. Так что в целом информация из этого раздела вам пригодятся, но вряд ли ею придется пользоваться каждый день.

Поля произвольного типа

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

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs, FlexibleContexts #-}

import Database.Persist
import Database.Persist.Sqlite
import Database.Persist.TH

data Employment = Employed | Unemployed | Retired
    deriving (Show, Read, Eq)
derivePersistField "Employment"

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name String
    employment Employment
|]

main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll

    insert $ Person "Bruce Wayne" Retired
    insert $ Person "Peter Parker" Unemployed
    insert $ Person "Michael" Employed

derivePersistField позволяет хранить данные в базе, используя строковые поля, а также реализует сериализацию с помощью экземпляров классов Read и Show вашего типа данных. Это может быть не так эффективно, как использование целых чисел, зато удобнее вот в каком плане. Даже если в будущем вы добавите новые конструкторы, данные в базе останутся валидными.

Persistent: сырой SQL

Пакет Persistent предоставляет типобезопасный интерфейс к хранилищу данных. Он старается не зависить от используемого бэкенда, например, не полагаясь на реляционные возможности SQL. По моему опыту в 95% случаев вы с легкостью решите стоящую перед вами задачу, используя высокоуровневый интерфейс. (В действительности, большинство моих веб-приложений используют только высокоуровневый интерфейс.)

Но иногда возникает желание использовать возможности, специфичные для конкретного бэкенда. Одной из таких возможностей, которую мне приходилось использовать, был полнотекстовый поиск. В этом случае в SQL-запросе требуется использовать LIKE, который не моделируется в Persistent. Давайте попробуем найти всех людей с фамилией «Snoyman» и вывести найденные записи.

Примечание: На самом деле, вы можете воспользоваться оператором LIKE с помощью нормального синтаксиса, поскольку в Persistent 0.6 была добавлена возможность, позволяющая использовать операторы, специфичные для бэкенда. Но это все равно хороший пример, так что давайте рассмотрим его.

{-# LANGUAGE OverloadedStrings, TemplateHaskell, QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, GADTs, FlexibleContexts #-}

import Database.Persist.Sqlite (withSqliteConn)
import Database.Persist.TH (mkPersist, persist, share, mkMigrate, sqlSettings)
import Database.Persist.GenericSql (runSqlConn, runMigration, SqlPersist)
import Database.Persist.GenericSql.Raw (withStmt)
import Data.Text (Text)
import Database.Persist
import Database.Persist.Store (PersistValue)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Conduit as C
import qualified Data.Conduit.List as CL

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    name Text
|]

main :: IO ()
main = withSqliteConn ":memory:" $ runSqlConn $ do
    runMigration migrateAll
    insert $ Person "Michael Snoyman"
    insert $ Person "Miriam Snoyman"
    insert $ Person "Eliezer Snoyman"
    insert $ Person "Gavriella Snoyman"
    insert $ Person "Greg Weber"
    insert $ Person "Rick Richardson"

    -- Persistent не предоставляет ключевого слова LIKE, но нам
    -- хотелось бы получить всю семью Snoyman'ов...
    let sql = "SELECT name FROM Person WHERE name LIKE '%Snoyman'"
    C.runResourceT $ withStmt sql []
                C.$$ CL.mapM_ $ liftIO . print

Также существует высокоуровневая поддержка сериализации. Подробности вы можете найти в Haddock-документации по API.

Интеграция с Yesod

Итак, вы прониклись всей мощью Persistent. Как теперь интегрировать его с Yesod-приложением? Если вы использовали автоматическую генерацию шаблона приложения, большая часть работы уже была проделана за вас. Но, по традиции, сейчас мы проделаем все вручную, чтобы лучше понять, как все устроено.

Пакет yesod-persistent представляет собой «клей» между Persistent и Yesod. Он предоставляет класс типов YesodPersist, который стандартизует доступ к базе данных с помощью runDB. Вот как это выглядит в действии:

{-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, FlexibleContexts, TemplateHaskell, OverloadedStrings, GADTs, MultiParamTypeClasses #-}

import Yesod
import Database.Persist.Sqlite

-- Определяем наши сущности, как обычно
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persist|
Person
    firstName String
    lastName String
    age Int Gt Desc
    deriving Show
|]

-- Мы являемся держателями пула соединений. Когда программа
-- инициализируется, мы создаем начальный пул, и каждый раз, когда
-- нам нужно произвести действие, мы выделяем соединение из пула.
data PersistTest = PersistTest ConnectionPool

-- Мы создаем один-единственный маршрут для доступа к человеку. Это
-- довольно распространенная практика, когда в маршрутах
-- используется Id.
mkYesod "PersistTest" [parseRoutes|
/person/#PersonId PersonR GET
|]

-- Тут ничего особенного
instance Yesod PersistTest

-- Теперь нам нужно определить экземпляр класса Yesod Persist, который
-- будет следить за тем, какой бэкенд мы используем и как следует
-- выполнять действия
instance YesodPersist PersistTest where
    type YesodPersistBackend PersistTest = SqlPersist

    runDB action = do
        PersistTest pool <- getYesod
        runSqlPool action pool

-- Мы просто возвращаем строковое представление человека
-- или ошибку 404, если такой Person не существует
getPersonR :: PersonId -> Handler RepPlain
getPersonR personId = do
    person <- runDB $ get404 personId
    return $ RepPlain $ toContent $ show person

openConnectionCount :: Int
openConnectionCount = 10

main :: IO ()
main = withSqlitePool "test.db3" openConnectionCount $ \pool -> do
    runSqlPool (runMigration migrateAll) pool
    runSqlPool (insert $ Person "Michael" "Snoyman" 26) pool
    warpDebug 3000 $ PersistTest pool

Тут есть два важных момента. Для выполнения действий над базой данных в обработчике используется runDB. Внутри runDB вы можете использовать все те функции, о которых шла речь выше, например, insert и selectList.

Примечание: runDB имеет тип runDB :: YesodDB sub master a -> GHandler sub master a. YesodDB определен, как: type YesodDB sub master = YesodPersistBackend master (GHandler sub master) Поскольку он построен на ассоциативном типе YesodPersistBackend, используется подходящий для текущего сайта бэкенд.

Другая новая фишка — это get404. Эта функция работает в точности, как get, только вместо того, чтобы возвращать Nothing, когда результат не может быть найден, она возвращает страницу с сообщением об ошибке 404. В функции getPersonR использован очень распространенный в реальных приложениях на Yesod подход — значение получается через функцию get404, а затем в зависимости от результата возвращается ответ.

Заключение

Persistent приносит строгую типизацию языка Haskell в ваш слой доступа к данным. Вместо того, чтобы писать склонный к возникновению ошибок, нетипизированный доступ к данным, или вручную писать шаблонный код сериализации, вы можете автоматизировать работу, используя Persistent.

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

Persistent напрямую интегрируется в общий рабочий процесс с Yesod. И речь тут идет не только о небольших пакетах вроде yesod-persistent — пакеты вроде yesod-form и yesod-auth также прекрасно взаимодействуют с Persistent.

Метки: , , , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.