Работа с 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> :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

Метки: , , , .


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