haskell-postgresql-simple/

Помните, как около года назад мы учились работать с базами данных в 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> :set -XOverloadedStrings
ghci> :m + Database.PostgreSQL.Simple
ghci> conn <- connectPostgreSQL «host=’localhost’ port=5432 dbname=’database’ user=’eax’ password=’qwerty'»

Вместо функции connectPostgreSQL также можно воспользоваться функцией connect, которая принимает аргумент типа ConnectInfo:

ghci> :t defaultConnectInfo
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 -XTemplateHaskell
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 <- query_ conn [sql| select 3+2, true; |] :: IO [(Int, Bool)]
ghci> xs
[(5,True)]

Но что делать, если список возвращаемых столбцов задается пользователем и потому на этапе компиляции мы сами не знаем, какого размера понадобится кортеж? Естественно, использовать списки! Но раз списки могут содержать значения только одного типа, все возвращаемые значения нужно привести, например, к ByteString, String или Text:

ghci> rows <- query_ conn «select query,user,ip,(time :: varchar) from requests» :: IO [[Maybe ByteString]]

Если возвращаемые столбцы могут иметь значение null (например, в запросе выше user может быть null, если пользователь не был залогинен), тип возвращаемых значений следует завернуть в Maybe.

А что будет в случае, если результат выполнения запроса не удастся привести к ожидаемому типу? При таком раскладе postgresql-simple бросит исключение на этапе исполнения.

Функция query_ предназначена для запросов, возвращающих какой-то результат. Для выполнения insert-, update- и delete-запросов используйте функцию execute_:

ghci> execute_ conn [sql|delete from requests where ip = ‘127.0.01’|]
1

Функция возвращает число зааффекченых строк. Помимо функций query_ и execute_ есть аналогичные функции без подчеркивания на конце. Они принимают дополнительный параметр, представляющий собой значения, подставляемые в SQL-запрос на место знаков вопроса:

query   :: (FromRow r, ToRow q) => Connection -> Query -> q -> IO [r]
execute :: ToRow q => Connection -> Query -> q -> IO GHC.Int.Int64

Для закрытия соединения используйте функцию close:

ghci> :t 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 :: [ ( FilePath , BS . ByteString ) ]
sortedMigrations =
let unsorted = $ ( embedDir «data/migrations» )
in L . sortBy ( compare `on` fst ) unsorted

Вы спросите, почему бы просто не прочитать содержимое файлов при запуске программы? Дело в том, что это немного неудобно. Во время разработки нашего приложения миграции следует искать в ./data/migrations, а после раскладки на сервер — в каком-нибудь /var/lib/project/ или еще где-то. Все это сложно, не очевидно, требует написания лишнего кода и лишней отладки. Намного проще подключить один дополнительный пакет, написать три строчки кода и не греть лишний раз мозг, просто таская все миграции в самом бинарнике.

В итоге функция прогона миграций будет выглядеть как-то так:

runMigrations :: Pool Connection -> IO ( )
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 при помощи пакета fast-logger, а также о механизме middleware в Scotty

EnglishRussianUkrainian