Haskell и хождение в базы данных с помощью HDBC

31 июля 2013

Сегодня мы научимся работать с реляционными базами данных из Haskell. Будет написана небольшая «телефонная книга» с CLI, которая будет хранить наши контакты в PostgreSQL. В мире Haskell есть много библиотек для работы с базами данных. Мы воспользуемся HDBC.

Как написано в самой документации по HDBC, в данном пакете многое было скопировано с Perl‘ового DBI. Пакет HDBC предоставляет единый интерфейс для работы со всеми СУБД. Детали работы с конкретными СУБД описаны в отдельных пакетах, называемых драйверами. Такой подход позволяет легко переходить с одной базы данных на другую, конечно, если только в приложении не используются возможности, предоставляемые только одной СУБД. Для работы с PostgreSQL через HDBC понадобится драйвер HDBC-postgresql. На Hackage также доступны драйверы для SQLite и MySQL. Кроме того, предусмотрен драйвер для работы по ODBC. Это позволяет работать из Haskell с Oracle, IBM DB2, Microsoft SQL Server и другими СУБД, поддерживающими данный API.

В Ubuntu перед сборкой HDBC-postgresql нужно сказать:

sudo apt-get install postgresql-server-dev-9.1

Из пакета HDBC-postgresql понадобится единственная функция:

connectPostgreSQL :: String -> IO Connection

Как вы уже догадались, она устанавливает соединение с PostgreSQL. Интересно, что функция является ленивой. Если по каким-то причинам приложение ничего не будет делать с базой данных, соединение не будет установлено.

Первый аргумент функции connectPostgreSQL генерируется примерно так:

getConnectString :: IO String
getConnectString = do
  conf <- loadConfig
  let dbconf = subconfig "database" conf
  [name, user, pass] <- mapM (\n -> require dbconf n :: IO String)
                             ["name", "user", "pass"]
  host <- lookupDefault "localhost" dbconf "host" :: IO String
  port <- lookupDefault 5432 dbconf "port" :: IO Int
  return $ "host=" ++ host ++ " port=" ++ show port ++ " dbname=" ++
           name ++ " user=" ++ user ++ " password=" ++ pass
  where
    fname = ".phonebookrc"
    loadConfig =
      catch (load [Required $ "$(HOME)/" ++ fname])
            (\e ->
              do mapM_ putStrLn $ ["Failed to open ~/" ++ fname, ""] ++
                                  usage fname
                 throw (e :: IOException))

Параметры подключения к СУБД хранятся в файле ~/.phonebookrc. Для парсинга конфига используется уже знакомый нам пакет configurator. Перед началом работы с базой данных нужно создать в ней таблицу phonebook:

CREATE TABLE phonebook (id SERIAL PRIMARY KEY, name VARCHAR(64), phone VARCHAR(64), last_changed TIMESTAMP);

Основные функции в пакете HDBC следующие.

withTransaction :: IConnection conn => conn -> (conn -> IO a) -> IO a

Выполнение запросов внутри транзакции.

run :: IConnection conn => conn -> String -> [SqlValue] -> IO Integer

Выполнить запрос и вернуть количество затронутых строк. Отлично подходит для выполнения простых INSERT-, UPDATE- и DELETE-запросов.

quickQuery'
  :: IConnection conn =>
     conn -> String -> [SqlValue] -> IO [[SqlValue]]

Выполнить запрос и вернуть полученные в результате его выполнения строки. Специально для выполнения SELECT-запросов. Эта функция является строгой. Есть аналогичная ленивая функция quickQuery, без штриха в конце имени.

prepare :: IConnection conn => conn -> String -> IO Statement

Приготовиться к выполнению запроса.

execute :: Statement -> [SqlValue] -> IO Integer

Выполнить приготовленный с помощью функции prepare запрос с заданными параметрами. Для SELECT-запросов всегда возвращает 0, в остальных случаях — число затронутых строк.

executeMany :: Statement -> [[SqlValue]] -> IO ()

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

fetchAllRows :: Statement -> IO [[SqlValue]]

Возвращает все строки, полученные в результате выполнения запроса. Также для аналогичных целей пакет HDBC предоставляет функции fetchAllRows’, fetchRow, fetchRowAL, fetchRowMap, fetchAllRowsAL, fetchAllRowsAL’, fetchAllRowsMap, fetchAllRowsMap’. Функции, в чьем имени есть штрих, являются строгими. Наличие в имени функции «AL» означает, что функция возвращает ассоциативный список. Если в имени есть «Map», значит вместо списка значений или списка пар из имени столбца и значения функция возвращает Map.

disconnect :: IConnection conn => conn -> IO ()

Отсоединиться от базы данных.

handleSqlError :: IO a -> IO a

Устаревшая функция, сохраненная для обратной совместимости. Раньше она использовалась для перехвата динамических исключений, которые в наше время остались только в легаси коде. Не используйте ее. Исключения, бросаемые в HDBC, прекрасно ловятся традиционными функциями из Control.Exception.

Все это выглядит не слишком сложно, правда? Теперь рассмотрим код модуля Phonebook.Storage, предоставляющего основные функции нашей с вами «телефонной книги».

module Phonebook.Storage (
    create, read, update, delete,
    ContactId, Name, Phone
  ) where

import Prelude hiding (read)
import Database.HDBC
import qualified Data.ByteString.Char8 as BS

type ContactId = Integer
type Name = String
type Phone = String

Здесь просто перечисляются экспортируемые функции и объявляются синонимы типов. Функция read модуля Prelude скрывается, потому что в модуле Phonebook.Storage содержится функция с таким же именем. Если этого не сделать, становится непонятно, какая из двух функций экспортируется модулем.

create :: IConnection a => Name -> Phone -> a -> IO Bool
create name phone conn =
  withTransaction conn (create' name phone)

create' name phone conn = do
  changed <- run conn query [SqlString name, SqlString phone]
  return $ changed == 1
  where
    query = "insert into phonebook (name, phone, last_changed)" ++
            " values (?, ?, now())"

read :: IConnection a => a -> IO [(ContactId, Name, Phone)]
read conn = do
  rslt <- quickQuery' conn query []
  return $ map unpack rslt
  where
    query = "select id, name, phone from phonebook order by id"
    unpack [SqlInteger cid, SqlByteString name, SqlByteString phone] =
      (cid, BS.unpack name, BS.unpack phone)
    unpack x = error $ "Unexpected result: " ++ show x

update :: IConnection a => ContactId -> Name -> Phone -> a -> IO Bool
update cid name phone conn =
  withTransaction conn (update' cid name phone)

update' cid name phone conn = do
  changed <- run conn query
                 [SqlString name, SqlString phone, SqlInteger cid]
  return $ changed == 1
  where
    query = "update phonebook set name = ?, phone = ?," ++
            " last_changed = now() where id = ?"

delete :: IConnection a => ContactId -> a -> IO Bool
delete cid conn =
  withTransaction conn (delete' cid)

delete' cid conn = do
  changed <- run conn "delete from phonebook where id = ?"
                 [SqlInteger cid]
  return $ changed == 1

Довольно скучная реализация функций create, read, update и delete (CRUD). Собственно, это и есть весь модуль. В модуле Phonebook.Utils находится уже рассмотренная функция чтения конфига, а также не представляющее никакого интереса usage-сообщение, которое появляется в случае, если конфиг отсутствует. Еще строк 60 кода находятся в модуле Phonebook.Interface.CLI.

В заключение хотелось бы отметить следующее. Во-первых, не все драйверы баз данных одинаково полезны. Например, если вы решите использовать HDBC-mysql в многопоточном приложении, вас может ждать неприятный сюрприз. Во-вторых, обратите внимание, что для хранения имен и телефонов мы использовали varchar, а не char. Если использовать char, HDBC будет отдавать имена и телефоны с кучей пробелов на конце, которые придется выпиливать самостоятельно. Наконец, если попытаться создать контакт со слишком длинным именем или телефоном, программа бросит исключение. В реальных приложениях нужно либо ловить такие исключения, либо как следует проверять параметры запросов.

Ссылки по теме:

  • Глава Using Databases в книге Real World Haskell;
  • HDBI — форк HDBC с несколькими существенными отличиями;
  • Судя по англоязычным блогам, для работы с PostgreSQL многие используют пакет postgresql-simple;
  • Если для работы с базами данных вы предпочитаете ORM-подобные решения, обратите внимание на пакет persistent;

Все исходники к этой заметке вы найдете в этом архиве. Инструкции по его сборке вы найдете здесь.

Дополнение: Работа с PostgreSQL в Haskell при помощи замечательных пакетов postgresql-simple и postgresql-simple-migration

Дополнение: Пишем простой RESTful сервис с использованием Scotty

Метки: , , .


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