HRRからDocker内のPostgreSQLを操作してみた
SQLジェネレーターのHRR(Haskell Relational Record)を使ってみました. Dockerコンテナの準備からデータ挿入まで解説します.
プロジェクトの作成方法から丁寧に解説していきます(後々自分のためにもなるしね).
環境
- macOS Mojave (10.14.3)
- Docker version 18.09.1, build 4c52b90
- stack Version 1.9.3
- ghc version 8.6.4
- lts-13.12
サンプルコード
こちらのリポジトリで管理しています. 自由にご利用ください.
利用するパッケージ
主要パッケージを依存関係とともに紹介します.
HDBC
はHaskellプログラムからDBに接続するための基本的な機能を提供しています. 今回はPostgreSQLを使うのでHDBC-postgresql
で定義されているPostgreSQL用のドライバを利用します.
基本的にHRR
と言ったらrelational-query-HDBC
, relational-query
, persistable-record
の3つのこと指すんだと思います. いろんな機能があります(全然把握できてないのでブログ更新しつつ全体像つかめたらいいな). 日本人の方が中心となって開発されています.
プロジェクトの作成
stack new
で新規プロジェクトを作成します.
stack --resolver lts-12.13 new hrr_experiments --bare
--bare
オプションをつけることで, 現在のディレクトリに各ファイルを用意してくれます. このオプションをつけない場合は, 現在のディレクトリにhrr_experiments
というディレクトリが作成され, その中に各ファイルが展開されます.
僕はいつもGitHubで空っぽのリポジトリを作成して, それをローカルにclone
してくる方法をとっているため, --bare
オプションが必要です.
PotgreSQLの準備
スキーマファイル
テーブルを定義するSQLファイルを用意します. 今回は./db/docker-entrypoint-initdb.d
というディレクトリに以下のSQLファイルを作成しました.
-- ./db/docker-entrypoint-initdb.d/schema.sql
CREATE TABLE country (
id SERIAL PRIMARY KEY,
country_name text NOT NULL
);
INSERT INTO
country (country_name)
VALUES
('Japan'),
('China'),
('Australia'),
('Russia');
文字列はシングルクォートで囲わなければならないところに注意ですね!(Haskellerがやらかしそうなミス)
テーブル名が単数形なのは後で説明します.
DBサーバー
今回はローカルのDBサーバーとして, PostgresのDockerコンテナを利用します. プロジェクトルートに次のComposeファイルを用意しましょう.
version: '3'
services:
db:
image: postgres:11.1-alpine
environment:
- POSTGRES_PASSWORD=password
- POSTGRES_USER=user
- POSTGRES_DB=test
volumes:
- ./db/data:/var/lib/postgresql/data
- ./db/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
環境変数を上のように設定することで, ユーザー名やパスワードを設定することができます. ポートフォワーディングも忘れずに.
マウントしている2つのボリュームについても補足しておきましょう.
data
ひとつめはDBデータを永続的にするために設定しています. DBデータが常にプロジェクト配下の./db/data
ディレクトリに保存されます.
docker-entrypoint-initdb.d
コンテナ内の/docker-entrypoint-initdb.d
ディレクトリは特別な役割を持っています. それはコンテナ起動時に, このディレクトリ内にある*.sql
, *.sql.gz
, *.sh
という拡張子を持ったファイルを実行してくれます.
つまりコンテナが起動し終わった時点で, schema.sql
で定義したテーブルが用意された状態が得られるということですね.
HRRからDBに接続
Haskellではあらゆるものが型で表現されるわけですが, HRRにおいてDBへの接続を表す型クラスはHDBC
パッケージのDatabase.HDBC
モジュールで定義されているIConnection
です.
HDBC-postgresql
パッケージではIConnection
のインスタンスであるConnection
という型が定義されています. これがDB接続を表現している型というわけですね.
依存パッケージの指定
利用するパッケージを設定ファイルに追加しましょう.
# package.yaml
dependencies:
- base >= 4.7 && < 5
- HDBC
- HDBC-postgresql
- relational-query-HDBC
- relational-quer
# stack.yaml
extra-deps:
- HDBC-postgresql-2.3.2.6
HDBC-postgresql
はスナップショットに含まれていないので個別に指定する必要があります.
DB接続
準備が整ったので, DBに接続するためのコードを書いていきましょう. src
ディレクトリにDB.hs
というファイルを用意してみました.
-- ./src/DB.hs
module DB where
import Database.HDBC.PostgreSQL (Connection, connectPostgreSQL)
import Database.HDBC.Schema.PostgreSQL (driverPostgreSQL)
connectPG :: IO Connection
connectPG = connectPostgreSQL $
"host=localhost"
++ " port=5432"
++ " user=user"
++ " dbname=test"
++ " password=password"
++ " sslmode=disable"
これで終わりです. ユーザー名等はComposeファイルで設定したものを指定してくださいね.
テーブルに対応した型の定義
HRRはデータベースに存在するテーブルに対応した型をTemplate Haskellを使って生成してくれます. やってみましょう.
コード
./src/Entity
にCountry.hs
を作成します.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}
module Entity.Country where
import Database.HDBC.Query.TH (defineTableFromDB)
import Database.HDBC.Schema.PostgreSQL (driverPostgreSQL)
import DB (connectPG)
import GHC.Generics (Generic)
$(defineTableFromDB
connectPG
driverPostgreSQL
"public"
"country"
[''Show, ''Generic])
言語拡張がいかついですが, これがCountry
型を定義するためのコードです.
テーブル名をCamelCaseに直したものが型になります. Countries
ではなくCountry
という型を作りたかったがために, countries
ではなくcountry
というテーブル名にしたのです.
生成された型を確認
本当にCountry
という型が生成されるのかを確認してみましょう.
まずはDBを起動しましょう.
$ docker-compose up -d
次にGHCiを起動します.
$ stack ghci
問題がなければ起動ができるはずです. するとこんな感じのプロンプトが表示されるかと思います.
*Main DB Entity.Country>
ここで, 以下のコマンドを実行します.
*Main DB Entity.Country> :browse Entity.Country
すると以下のような出力が得られるでしょう(適宜改行や空行を追加しています).
data Country
= Country {Entity.Country.id :: !GHC.Int.Int32,
countryName :: !String}
columnOffsetsCountry :: GHC.Arr.Array Int Int
tableOfCountry :: Database.Relational.Table.Table Country
country :: Database.Relational.Monad.BaseType.Relation () Country
insertCountry :: Database.Relational.Type.Insert Country
insertQueryCountry ::
Database.Relational.Monad.BaseType.Relation p Country
-> Database.Relational.Type.InsertQuery p
Entity.Country.id' ::
Database.Relational.Pi.Unsafe.Pi Country GHC.Int.Int32
countryName' :: Database.Relational.Pi.Unsafe.Pi Country String
selectCountry ::
Database.Relational.Type.Query GHC.Int.Int32 Country
updateCountry ::
Database.Relational.Type.KeyUpdate GHC.Int.Int32 Country
たしかにCountry
という型が定義されているのがわかりますね.
それに加え, insertやselect用の関数まで用意されています.
クエリ発行
コード
さて次はレコードの検索をしてみましょう. さっき作ったCountry.hs
にコードを追加します.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TemplateHaskell #-}
module Entity.Country where
import Database.HDBC.Query.TH (defineTableFromDB)
import Database.HDBC.Record.Query (runQuery') -- 追加
import Database.HDBC.Schema.PostgreSQL (driverPostgreSQL)
import Database.Relational.Type (relationalQuery) -- 追加
import DB (connectPG)
import GHC.Generics (Generic)
$(defineTableFromDB
connectPG
driverPostgreSQL
"public"
"country"
[''Show, ''Generic])
-- 以下を追加
showAllCountries :: IO ()
showAllCountries = do
conn <- connectPG
countries <- runQuery' conn (relationalQuery country) ()
mapM_ print countries
これでcountry
テーブルのレコードすべてを列挙する関数ができました.
関数の説明
ここで登場した関数を紹介します.
runQuery
runQuery'
はrelational-query-HDBC
パッケージ内で定義されています.
runQuery' ::
(IConnection conn, ToSql SqlValue p, FromSql SqlValue a)
=> conn -- DB接続 ここではconnectPGの中身
-> Query p a -- a型の値を返すクエリ ここではaはCountry
-> p -- パラメーターらしい ここでは使わないので()
-> IO [a] -- 検索結果
同じモジュール内でrunQuery
という関数も定義されていますが, こちらはrunQuery'
のlazyバージョンです.
relationalQuery
relationalQuery
はrelational-query
パッケージで定義されています.
relationalQuery :: Relation p r -> Query p r
上で見たように, HRRが生成した関数country
は
country :: Relation () Country
という型を持つので, 型が合っていることがわかりますね.
実行してみる
ではGHCi上で実行してみましょう.
> Entity.Country.showAllCountries
Country {id = 1, countryName = "Japan"}
Country {id = 2, countryName = "China"}
Country {id = 3, countryName = "Australia"}
Country {id = 4, countryName = "Russia"}
データ挿入
次はデータの挿入をやってみます.
:browse
したときに, HRRがinsertCountry
という関数を作ってくれていることに気づいた方もいるかもしれません. しかし今回これは使いません.
この理由も含め, 順を追って説明していきます.
コード
まずは追加するコードをお見せします.
module Entity.Country where
-- これをimport
import Database.HDBC (commit)
import Database.HDBC.Record.Insert (runInsert)
import Database.Relational.Type (insert, relationalQuery)
-- その他importは省略
-- 以下を追加
testInsert :: IO ()
testInsert = do
conn <- connectPG
runInsert conn (insert countryName') "USA"
commit conn
コードの解説
runInsert
はrelatinal-query-HDBC
パッケージで定義されています.
runInsert ::
(IConnection conn, ToSql SqlValue a)
=> conn -- DB接続
-> Insert a -- SQLのINSERT文に対応
-> a -- 挿入データ
-> IO Integer -- 挿入されたレコード数
HRRが生成したinsertCountry
を使うとしたら, 以下を考えることになります.
runInsert ::
(IConnection conn, ToSql SqlValue a)
=> conn
-> Insert Country
-> Country
-> IO Integer
しかしここで問題が発生します. Country
型の各フィールドは正格評価です. つまりAUTO INCREMENT
されるid
も挿入時点で用意しなければならないということです.
これは面倒ですよね. というかやりたくありません.
というわけで, 考えたいのは以下です.
runInsert ::
(IConnection conn, ToSql SqlValue a)
=> conn
-> Insert 国名
-> 国名
-> IO Integer
これを実現してくれるのが, insert countryName'
なんです. 型を確認しておきましょう.
insert
はrelational-query
パッケージで定義されています.
insert ::
(PersistableWidth r, TableDerivable r)
=> Pi r r'
-> Insert r'
countryName' :: Pi Country String
Pi
という型は射影を表しています.
数学における射影とは, 例えば平面上の点から第一成分だけを取り出す操作
\[ (x,y) \mapsto x \]
などのことを言います.
Country
はid
とcountryName
の2つの成分を持っていて, そのうちのcountryName
だけを取り出す操作を表しているのがcountryName'
というわけです.
実行してみる
GHCiで実行してみましょう. 挿入されたレコード数は捨てているので何も表示されませんが, たしかにレコードが登録されていることがわかります.
> testInsert
> showAllCountries
Country {id = 1, countryName = "Japan"}
Country {id = 2, countryName = "China"}
Country {id = 3, countryName = "Australia"}
Country {id = 4, countryName = "Russia"}
Country {id = 5, countryName = "USA"}