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 базе данных. Соответствующая таблица может выглядеть как-то так:
И если вы используете такую СУБД, как PostgreSQL, вы можете быть уверены, что СУБД никогда не сохранит какой-нибудь дополнительный текст в поле age. (Нельзя сказать то же самое в отношении SQLite, однако пока что забудем об этом.) Для отображения этой таблицы вы можете захотеть создать примерно такой тип данных:
{ personName :: Text
, personAge :: Int
}
Все выглядит вполне типобезопасно — схема базы данных соответствует типу данных в Haskell, СУБД гарантирует, что некорректные данные никогда не будут сохранены в таблице, и все в целом выглядит прекрасно. До поры до времени.
Вы хотите получить данные из СУБД, которая в свою очередь предоставляет их в нетипизированном формате.
- Вы хотите найти все людей, старше 32-х лет, но по ошибке пишете «тридцать два» в SQL-запросе. И знаете что? Все прекрасно скомпилируется и вы не узнаете о проблеме до тех пор, пока не запустите программу.
- Вы решили найти первых десятерых человек в алфавитном порядке. Нет проблем… до тех пор, пока вы не сделаете опечатку в SQL-запросе. И снова, вы не узнаете об этом до тех пор, пока не запустите программу.
- В языках с динамической типизацией ответом на эти проблемы является модульное тестирование. Проверьте, что для всего, что может пойти не так, вы не забили написать тест. Но как, я полагаю, вы уже знаете, это не очень согласуется с подходом, принятом в Yesod. Мы предпочитаем использовать преимущества статической типизации языка Haskell для нашей собственной защиты, насколько это возможно, и хранение данных не является исключением.
Итак, вопрос остается открытым: как мы можем использовать систему типов языка Haskell, чтобы исправить положение?
Типы
Как и в случае с маршрутами, нет ничего невероятно сложного в типобезопасном доступе к данным. Он всего лишь требует написания монотонного, подверженного ошибкам избыточного шаблонного кода. Как обычно, это означает, что мы можем использовать систему типов для того, чтобы избежать лишних ошибок. А чтобы не заниматься нудной работой, мы вооружимся Template Haskell.
Примечание: В ранних версиях Persistent очень активно использовался Template Haskell. Начиная с версии 0.6 используется новая архитектура, позаимствованная из пакета groundhog. Благодаря новому подходу существенная часть нагрузки была переложена на фантомные типы.
PersistValue является основным строительным блоком в Persistent. Этот тип представляет данные, посылаемые базе данных или получаемые от нее. Вот его определение:
| 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): от вас требуется объявить сущности только один раз. Рассмотрим следующий пример:
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 для каждого определенного типа данных;
Приведенный выше пример генерирует код, который выглядит примерно следующим образом:
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.
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 делает шаг вперед и протягивает руку помощи. Только нужно его об этом попросить. Вот как примерно это выглядит:
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:
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-запросов во время миграций, или же просто для логирования происходящих миграций.
Уникальность
Помимо объявления полей у сущности мы также может объявлять ограничение уникальности. Типичный пример — это требование уникальности имени пользователя:
username Text
UniqueUsername username
В то время, как имя каждого поля должно начинаться с маленькой буквы, ограничение уникальности должно начинаться с большой буквы.
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. В конце приведенного выше примера используется следующий конструктор:
Примечание: В случае использования MongoDB ограничение уникальности не может быть использовано — вы должны создать уникальный индекс по полю.
Запросы
В зависимости от вашей цели, могут быть использованы различные запросы к базе данных. В некоторых запросах используется численный ID, когда в других происходит фильтрация по значению поля. Запросы также различаются по количеству значений, которые они возвращают. Одни должны возвращать не более одного результата, другие же могут возвращать множество результатов.
В связи с этим Persistent предоставляет множество различных функций для выполнения запросов. Как обычно, мы стараемся закодировать с помощью типов столько инвариантов, сколько возможно. Например, если запрос может возвращать либо 0, либо 1 результат, используется обертка Maybe. Если же запрос может вернуть много результатов, возвращается список.
Выборка по ID
Простейший запрос, который может быть выполнен в Persistent — это выборка по ID. Поскольку в этом случае значение может существовать или не существовать, возвращаемое значение оборачивается в Maybe.
Использование функции get:
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:
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’ы предоставляют три различных возможности — сортировку, ограничение количества возвращаемых строк и смещение возвращаемого значения на заданное количество строк.
Примечание: Комбинация из ограничения и смещения очень важна, она позволяет реализовать эффективное разбиение на страницы в вашем веб-приложении.
Сразу перейдем к примеру с фильтрацией, а затем проанализируем его:
liftIO $ print people
Несмотря на простоту примера, необходимо отметить три момента:
- PersonAge является конструктором ассоциативного фантомного типа. Звучит ужасающе, однако действительно важно лишь то, что он однозначно определяет столбец «age» таблицы «person», а также знает, что возраст на самом деле является Int’ом. (В этом и состоит его фантомность.)
- Мы имеем дело с группой фильтрующих операторов пакета Persistent. Они довольно прямолинейны и делают в точности то, что вы от них ожидаете. Однако тут есть три тонких момента, которые я объясню ниже.
- Список фильтров объединяется логическим И, то есть, ограничение следует читать, как «возраст больше 25-и И возраст меньше или равен 30-и». Использование логического ИЛИ мы рассмотрим ниже.
Также имеется оператор с удивительным названием «не равно». Мы используем обозначение !=.'
, поскольку /=.
используется при UPDATE-запросах (ради «разделяй-и-устанавливай», о котором я расскажу позже). Не беспокойтесь, если вы воспользуетесь неверным оператором, компилятор предупредит вас. Еще два удивительных оператора — это «принадлежит множеству» и «не принадлежит множеству». Они обозначаются, соответственно, <-.
и /<-.
(оба с точкой на конце).
Что же касается логического ИЛИ, для него есть оператор ||.
. Например:
( [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-и лет, отсортированных по возрасту (более старшие идут первыми). Люди с одинаковым возрастом сортируются по фамилиям, а затем по именам.
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 в само значение. Другими словами, вместо:
… мы имели бы:
Одна из проблем сразу бросается в глаза. Как прикажете производить вставку? Если Person требуется ID, а ID возвращается функцией insert, которой в свою очередь требуется Person, мы получаем проблему курицы и яйца. Мы могли бы решить эту проблему, используя неопределенный ID, однако это верный способ нарваться на неприятности.
Вы скажете, хорошо, давайте попробуем что-то более безопасное:
Намного предпочтительнее писать 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 от значения, но в конце концов, это правильный подход, который в великой схеме вещей ведет к более хорошему, менее дырявому коду.
Обновление
Теперь подумаем об обновлении в контексте нашего обсуждения. Вот простейший способ сделать обновление:
michaelAfterBirthday = michael { personAge = 27 }
Однако в действительности этот код ничего не обновляет. Он просто создает новое значение типа Person, основанное на старом значении. Когда мы говорим об обновлении, мы имеем в виду не модификацию значений в Haskell. (И вправду, не стоило бы этого делать, поскольку данные в Haskell неизменяемы.)
На самом деле, мы ищем способ изменить строки в таблице. И проще всего сделать это с помощью функции update:
update personId [PersonAge =. 27]
resultsForPage 1 >>= liftIO . print
Функция update принимает два аргумента: ID и список Update’ов. Простейший способ обновления поля заключается в присвоении ему нового значения, однако этот способ не лучший. Что, если вы хотите увеличить чей-то возраст на единицу, но текущий возраст вам не известен? В Persistent предусмотрено и это:
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 принимает два аргумента: список фильтров и список обновлений, которые следует применить.
Иногда хочется просто заменить одно значение в базе данных на другое. Для этого (сюрприз!) есть функция replace:
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 | Удалить по множеству фильтров |
delete personId
deleteBy $ UniqueName "Michael" "Snoyman"
deleteWhere [PersonFirstName ==. "Michael"]
С помощью deleteWhere мы можем удалить вообще все данные из таблицы. Нужно только подсказать GHC, какая таблица нас интересует:
Атрибуты
До сих пор мы видели базовый синтаксис для наших persist-блоков — строка с именем сущности, за которой для каждого поля идет по одной строке с отступами, состоящей из двух слов, имени поля и типа данных поля. Persistent поддерживает не только это. После двух слов в строке вы можете указать произвольный список атрибутов.
Допустим, вы хотите, чтобы сущность Person имела необязательный возраст, а также время добавления его или ее в систему. Для сущностей, уже находящихся в базе данных, в качестве этого времени мы хотим использовать текущее время.
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 поле с любимым языком программирования:
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 позволяет использовать двойные кавычки для строк, содержащих пробелы. Например, если мы хотим сделать страной по умолчанию Российскую Федерацию, то должны написать:
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. Это может пригодиться при взаимодействии с существующими базами данных.
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 в связанную сущность. Вот как выглядит пример для человека с большим количеством машин:
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
С помощью этой техники вы можете определять отношения один-ко-многим. Чтобы определить отношение многие-ко-многим, нам понадобится связующая сущность, которая имеет отношения один-ко-многим с каждой из оригинальных таблиц. В данном случае будет хорошей идеей воспользоваться ограничением уникальности. Допустим, мы хотим смоделировать ситуацию, в которой мы отслеживаем, какие люди в каких магазинах делают покупки:
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. Простое решение этой проблемы заключается в определении синонима типа с другим суффиксом, например:
type IdIsNotTheSuffix = MyExistingTypeEndingInId
[persist|
Person
someField IdIsNotTheSuffix
Подробнее о типах
До сих пор мы говорили о Person и PersonId без особого объяснения, чем они на самом деле являются. В простейшем случае, если мы говорим только о реляционных базах данных, PersinId мог бы быть просто type PersonId = Int64
. Но в этом случае на уровне типов ничто не связывало бы PersinId и сущность Person. В результате вы могли бы по ошибке воспользоваться PersonId для получения Car. Для моделирования таких отношений мы используем фантомные типы. Итак, наш следующий наивный шаг был бы следующим:
type PersonId = Key Person
Примечание: До Persistent 0.6 мы использовали ассоциативные типы вместо фантомных типов. Таким способом также можно решить проблему, но фантомы справляются с ней лучше.
И это действительно отлично работает до тех пор, пока вы не столкнетесь с бэкендом, который не использует Int64 в качестве ID. И это далеко не теоретический вопрос, поскольку MongoDB для этих целей использует тип ByteString. Итак, нам нужно значение ключа, которое может содержать либо Int64, либо ByteString. Кажется, настало отличное время для применения суммарных типов:
Но на самом деле мы только ищем неприятности. В следующий раз нам попадется бэкенд, который использует в качестве ключа временные метки и нам придется ввести дополнительный конструктор для Key. Так может продолжаться какое-то время. К счастью, у нас уже есть суммарный тип, предназначенный для представления произвольных данных, PersistValue:
Но тут есть другая проблема. Скажем, у нас есть веб-приложение, которое принимает ID в качестве параметра от пользователя. Этому приложению придется принимать параметр, как Text, а затем пытаться преобразовать его в Key. Нет проблем, давайте напишем функцию, которая преобразовывает Text в PersistValue, а затем передадим возвращаемое ее значение в конструктор Key. Правильно?
Нет, неправильно. Мы пробовали, и это оказалось большим геморроем. Все закончилось тем, что нам пришлось принимать ключи, которых не может быть. Например, если мы имеем дело с SQL, ключ должен быть целым числом. Но при подходе, описанном выше, в качестве ключа мы вынуждены принимать произвольные текстовые данные. В результате мы получали 500-ые ошибки, потому что СУБД была в шоке от попыток сравнивать целочисленные поля с текстом.
Что нам действительно нужно, это способ преобразования текста в Key, но с учетом правил используемого бэкенда. И как только вопрос становится сформулирован таким образом, мы тут же получаем ответ — добавить еще одного фантома. В действительности, определение Key в Persistent следующее:
И это прекрасно работает. Теперь мы можем получить функцию Text -> Key MongoDB entity
и функцию Text -> Key SqlPersist entity
, после чего все работает, как по маслу. Но теперь есть другая проблема — отношения. Скажем, мы хотим представлять блоги и посты в блогах. Будем использовать следующее определение сущностей:
title Text
Post
title Text
blogId BlogId
Но как это будет выглядеть с точки зрения типа данных Key?
data Post = Post { postTitle :: Text, postBlogId :: Key <что должно быть здесь?> Blog }
Мы должны указать какой-то бэкенд. В теории, мы можем захардкодить SqlPersist или Mongo, но тогда наши типы данных будут работать только с одним бэкендом. Для одного приложения это может быть приемлемым, но как насчет библиотек с определениями типов данных, которые могут использоваться в различных приложениях с различными бэкендами?
Так что, все становится чуть сложнее. На самом деле, наши типы такие:
data PostGeneric backend = Post { postTitle :: Text, postBlogId :: Key backend (BlogGeneric backend) }
Обратите внимание, что мы все еще используем короткие имена для конструкторов и записей. Наконец, чтобы предоставить простой интерфейс для нормального кода, мы определяем кое-какие синонимы типов:
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:
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 была добавлена возможность, позволяющая использовать операторы, специфичные для бэкенда. Но это все равно хороший пример, так что давайте рассмотрим его.
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. Вот как это выглядит в действии:
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.
Метки: Haskell, Yesod, Перевод, СУБД, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.