Работа с PostgreSQL в Haskell при помощи замечательных пакетов postgresql-simple и postgresql-simple-migration
14 июля 2014
Помните, как около года назад мы учились работать с базами данных в Haskell при помощи пакета HDBC, а также его форка HDBI, который от HDBC почти ничем не отличается? Тогда я отмечал, что вместо HDBC некоторые предпочитают использовать пакет postgresql-simple. Давайте попробуем разобраться, что это за пакет такой и почему он интереснее, чем HDBC. А также заодно познакомимся с пакетом postgresql-simple-migration.
Напомню, что HDBC — это такой слой абстракции над базой данных, типа DBI из мира Perl или JDBC из мира Java. Вы можете устанавливать драйверы, специфичные для конкретной СУБД, и без проблем переключаться с одной базы данных на другую, не меняя ни строчки кода. Или даже поддерживать работу сразу с несколькими СУБД, давая пользователю возможность при помощи конфига выбирать конкретную. Конечно, при условии, что вы не завязались на уникальные возможности конкретной СУБД и не клали болт на тестирование совместимости.
Так вот, а postgresql-simple — это более легковесная штука, представляющая собой всего лишь биндинг к библиотеке libpq. С одной стороны, это менее круто, ведь нельзя переключаться с MySQL на PostgreSQL. Но с другой, на практике серверсайдный код и так сильно привязывается к конкретной СУБД, да и миграция любого серьезного объема данных с одной СУБД на другую без остановки приложения — задача весьма нетривиальная. Так что в действительности биндингов к libpq более, чем достаточно. Кроме того, как мы с вами скоро увидим, postgresql-simple умеет делать кое-какие классные вещи, которые не умеет HDBC.
Устанавливается пакет тривиально, поэтому давайте сразу откроем REPL и попробуем подрубиться к базе данных:
ghci> :m + Database.PostgreSQL.Simple
ghci> conn <- connectPostgreSQL "host='localhost' port=5432 dbname='database' user='eax' password='qwerty'"
Вместо функции connectPostgreSQL также можно воспользоваться функцией connect, которая принимает аргумент типа ConnectInfo:
defaultConnectInfo :: ConnectInfo
ghci> defaultConnectInfo
ConnectInfo {connectHost = "127.0.0.1", connectPort = 5432, connectUser = "postgres", connectPassword = "", connectDatabase = ""}
ghci> :t connect
connect :: ConnectInfo -> IO Connection
Попробуем выполнить простой select-запрос:
ghci> :set -XQuasiQuotes
ghci> :m + Database.PostgreSQL.Simple.SqlQQ
ghci> query_ conn [sql| select 2+2 |] :: IO [Only Int]
[Only {fromOnly = 4}]
Благодаря расширениям TemplateHaskell и QuasiQuotes мы можем использовать конструкцию [sql|код запроса|]
вместо "код запроса"
, что избавляет нас от необходимости многократно экранировать двойные кавычки в запросе. Также мы должны явно указать тип возвращаемого функцией query_ значения, так как GHCi не может вывести его из SQL-запроса или еще как-то. В данном случае тип возвращаемого значения — IO [Only Int]
. Почему IO — понятно, мы же ходим в базу. Список соответствует множеству возвращаемых строк. А что такое Only? Дело в том, что здесь возвращается единственное значение, но поскольку в Haskell нет типа «кортеж из одного элемента», используется обертка Only.
Соответственно, в запросах, возвращающих более одного столбца, следует использовать кортежи:
ghci> xs
[(5,True)]
Но что делать, если список возвращаемых столбцов задается пользователем и потому на этапе компиляции мы сами не знаем, какого размера понадобится кортеж? Естественно, использовать списки! Но раз списки могут содержать значения только одного типа, все возвращаемые значения нужно привести, например, к ByteString, String или Text:
Если возвращаемые столбцы могут иметь значение null (например, в запросе выше user может быть null, если пользователь не был залогинен), тип возвращаемых значений следует завернуть в Maybe.
А что будет в случае, если результат выполнения запроса не удастся привести к ожидаемому типу? При таком раскладе postgresql-simple бросит исключение на этапе исполнения.
Функция query_ предназначена для запросов, возвращающих какой-то результат. Для выполнения insert-, update- и delete-запросов используйте функцию execute_:
1
Функция возвращает число зааффекченых строк. Помимо функций query_ и execute_ есть аналогичные функции без подчеркивания на конце. Они принимают дополнительный параметр, представляющий собой значения, подставляемые в SQL-запрос на место знаков вопроса:
execute :: ToRow q => Connection -> Query -> q -> IO GHC.Int.Int64
Для закрытия соединения используйте функцию close:
close :: Connection -> IO ()
Напрямую вам вряд ли придется ею пользоваться, только передать один раз при использовании уже знакомого нам пакета resource-pool.
Что интересно в postgresql-simple, это то, что вы можете легко и непринужденно объявлять экземпляры классов типов FromRow и ToRow для собственных типов. Это автоматом избавляет вас от кучи головной боли, связанной с сериализацией и десериализацией. Кроме того, postgresql-simple позволяет использовать такие характерные только для PostgreSQL возможности, как механизм NOTIFY/LISTEN. Обо всем этом рассказывает заметка 24 Days of Hackage: postgresql-simple в чудесном блоге ocharles.org.uk, рекомендую к ознакомлению!
На этом можно было бы и закончить, но повествование было бы неполным без упоминания пакета postgresql-simple-migration. Схема базы данных в реальных приложениях постоянно меняется, и админам очень быстро надоедает выполнять их вручную. К счастью, процесс этот легко автоматизировать.
Нам снова понадобится немного магии Template Haskell. На Hackage есть такой пакет file-embed, в котором, помимо прочего, есть шаблон embedDir. На этапе компиляции этот шаблон берет все файлы из заданной директории и генерирует список пар имя_файла:содержимое, с которым мы сможем работать на этапе выполнения. С его помощью мы можем получить функцию, возвращающую наши миграции:
sortedMigrations =
let unsorted = $(embedDir "data/migrations")
in L.sortBy (compare `on` fst) unsorted
Вы спросите, почему бы просто не прочитать содержимое файлов при запуске программы? Дело в том, что это немного неудобно. Во время разработки нашего приложения миграции следует искать в ./data/migrations, а после раскладки на сервер — в каком-нибудь /var/lib/project/ или еще где-то. Все это сложно, не очевидно, требует написания лишнего кода и лишней отладки. Намного проще подключить один дополнительный пакет, написать три строчки кода и не греть лишний раз мозг, просто таская все миграции в самом бинарнике.
В итоге функция прогона миграций будет выглядеть как-то так:
runMigrations pool =
withResource pool $ \conn -> do
let defaultContext =
MigrationContext
{ migrationContextCommand = MigrationInitialization
, migrationContextVerbose = False
, migrationContextConnection = conn
}
migrations = ("(init)", defaultContext) :
[
(k, defaultContext
{ migrationContextCommand =
MigrationScript k v
})
| (k, v) <- sortedMigrations
]
forM_ migrations $ \(migrDescr, migr) -> do
writeLog $ "Running migration: " <> BS.pack migrDescr
res <- runMigration migr
case res of
MigrationSuccess -> return ()
MigrationError reason -> do
writeLog $ "Migration failed: " <> BS.pack reason
exitFailure
Выполняем ее в функции main сразу после создания пула соединений, и все. В базе данных будет создана дополнительная таблица schema_migrations, содержащая имена и контрольные суммы миграций, а также время, в которое они были выполнены. Чтобы создать новую миграцию, просто кладем sql-скрипт в каталог data/migrations, снабжая имя файла префиксом, обеспечивающим правильный порядок выполнения миграций, например, результат выполнения date +%s
или время в формате YYYYMMDDhhmmss. Алсо не забываем обернуть код в begin;
и commit;
. Можно создавать и удалять таблицы, переименовывать столбцы, переопределять хранимки, и так далее — словом, делать все что угодно. Persistent, к примеру, поддерживает миграции, но такой гибкости не дает.
Единственный тонкий момент, который следует иметь в виду, состоит в том, что при использовании Template Haskell перед сборкой проекта в Makefile обязательно должен делаться cabal clean
. Иначе может сложиться такая ситуация, что вы создали новую миграцию, но скомпилированная версия модуля с функцией sortedMigrations будет взята из кэша, так как код модуля не менялся. В итоге вы создаете новую миграцию, а приложение ее не выполняет!
В качестве источников дополнительной информации я бы советовал следующие:
- Есть аналогичный пакет mysql-simple, но без mysql-simple-migration;
- Примеры работы с postgresql-simple на blog.begriffs.com — раз и два;
- Пакет postgresql-orm, как бы ORM надстройка над postgresql-simple;
- Тема борьбы с опечатками в SQL-запросах раскрыта в пакете esqueleto;
И напоминаю, что я всегда рад любым вашим дополнениям и вопросам!
Метки: Haskell, PostgreSQL, СУБД, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.