環境
- 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
という型は射影を表しています。
数学における射影とは、例えば平面上の点から第一成分だけを取り出す操作
などのことを言います。
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"}