読者です 読者をやめる 読者になる 読者になる

Extending Query, Relational, Typeful, Composable

Haskell Advent Calendar 2013 の19日目のエントリーです。

HaskellSQL の複雑な検索式を記述するための、Relational Record というライブラリ(以下HRR)(https://github.com/khibino/haskell-relational-record) を作ったので紹介します。このライブラリを使うことで、検索式を部品化し、単純な検索式を組合せて、より複雑な検索式を組み立てることができます。

HRR は HaskellDB (http://hackage.haskell.org/package/haskelldb) を参考にした関係代数ベースのライブラリですが、カラム名の問題、外部結合、集約操作、Placeholderの問題点を克服する形で再構成しました。以下に順番に説明していきたいと思います。

結合について

SQLの結合における問題

この話の性質上、まずは SQL をそのまま利用した場合の問題について説明したいと思います。

たとえば以下のようなレコードを持つテーブルを考えてみましょう。

テーブル名 user

data User = User {
 userId   :: Int    -- user_id
 userName :: String -- user_name
}

テーブル名 group

data Group = Group {
 groupId   :: Int    -- group_id
 groupName :: String -- group_name
}

テーブル名 membership

data Membership = Membership {
 uid :: Int    -- uid
 gid :: Int    -- gid
}

それぞれユーザーの一覧とグループの一覧、グループのメンバーを表現しているとします。

これらのテーブルの上でメンバーのユーザー名とグループ名を全て出力するような結合クエリを考えれば、次のようになるでしょう。

SQL - user と group - 名前のみ

SELECT user_name, group_name
FROM   (user INNER JOIN membership ON user_id = uid)
       INNER JOIN group ON group_id = gid

クエリはテーブル式でもあるので、クエリをさらに結合することができます。

ユーザーにさらに親子関係があるようなケースを考えてみましょう。親のユーザーを parentId で指定するとします。

テーブル名 user - 親ID付き

data User = User {
 userId   :: Int    -- user_id
 userName :: String -- user_name
 parentId :: Int    -- parent_id
}

親子関係を使って結合したいので、先程のクエリを変更します。

SQL - user と group - 親ID付き

SELECT user_name, group_name, user_id, parent_id
FROM   (user INNER JOIN membership ON user_id = uid)
       INNER JOIN group ON group_id = gid

これを親子関係で自身に結合すると次のようになるでしょう。カラム名に重複があるのでテーブルのエイリアス名を使って修飾します。

SQL - user と group の親子結合

SELECT P.user_name, P.group_name,
       C.user_name, C.group_name
FROM
 (SELECT user_name, group_name, user_id, parent_id
  FROM   (user INNER JOIN membership ON user_id = uid)
         INNER JOIN group ON group_id = gid) AS P
 INNER JOIN
 (SELECT user_name, group_name, user_id, parent_id
  FROM   (user INNER JOIN membership ON user_id = uid)
         INNER JOIN group ON group_id = gid) AS C
 ON    P.parent_id = C.user_id

同じ userとgroup の結合クエリを複数回書かなければならない問題があります*1が、この結合クエリを実行して、プログラムから結果を使うのならこれはこれで問題ありません。

しかし、さらにこのクエリを結合に使いたくなったと考えたらどうでしょうか。

P.user_name や C.user_name はこのクエリ内ではカラム名が区別されますが、このクエリの外側から見た場合には同じ名前になってしまって名前が衝突します。このようなケースではカラム名をつけかえることになります。

SQL - user と group の親子結合 - 名前つけかえ

SELECT P.user_name as parent_uid, P.group_name as parent_gid,
       C.user_name as child_uid,  C.group_name as child_gid
FROM
 (SELECT user_name, group_name, user_id, parent_id
  FROM   (user INNER JOIN membership ON user_id = uid)
         INNER JOIN group ON group_id = gid) AS P
 INNER JOIN
 (SELECT user_name, group_name, user_id, parent_id
  FROM   (user INNER JOIN membership ON user_id = uid)
         INNER JOIN group ON group_id = gid) AS C
 ON    P.parent_id = C.user_id

すべてのクエリを結合の一部として再利用可能になるようにしておくことを考えると、この名前のつけかえは結合を行なうたびに行なう必要があることになります。このつけかえの手間は SQL の名前付けの戦略の都合によるもので本来は必要のないはずのものです。結合式を部品化しながら複雑な結合を組み上げていく際に結合しやすさを損なう原因となります。

SQLの結合における問題 - 問題点のまとめ
  • 同じクエリを再利用するときに SQL文字列をコピーする必要がある問題
  • クエリを結合するときにカラム名をつけかえる必要がある問題
HRR による結合

準備として必要な定義を import します。

HRR の各種コンビネータは以下で import できます。クエリを書いていくにはそれ以外にテーブル定義の読み込みが必要です。

user と group - 名前のみ

import Database.Relational.Query

それでは、HRR がSQLの結合における2つの問題をどのように解決するか見ていきます。まずは素直に user と group の結合を書いてみると以下のようになります。

user と group - 名前のみ

userAndGroup' :: Relation () (String, String)
userAndGroup' =  relation $ do
  u <- query user
  m <- query membership

  on $ u ! userId'  .=. m ! uid'

  g <- query group

  on $ g ! groupId' .=. m ! gid'

  return $ u ! userName' >< g ! groupName'

{-
 SELECT T0.user_name AS f0, T2.group_name AS f1
 FROM (EXAMPLE.user T0 INNER JOIN
       EXAMPLE.membership T1
       ON (T0.user_id = T1.uid))
       INNER JOIN EXAMPLE.group T2
       ON (T2.group_id = T1.gid)
 -}

HRR では Relation という型のクエリの式を書いておくと、それを SQL に変換することができます。

たとえば上の userAndGroup' はコメント内にあるような SQL に変換されます。query で結合に加えるテーブル式を指定し、on で結合条件を指定しています。
(!) は第1引数にレコード、第2引数にキーを受け取ってカラムを選択しています。(.=.)は SQL の = です。(><) で並べることでタプルの結果を作ることができます。

今回の例では親子関係で結合しようとしているので、結果のカラム選択を行なわずにレコードを取り出すことにしましょう。

user と group

userAndGroup :: Relation () (User, Group)
userAndGroup =  relation $ do
  u <- query user
  m <- query membership

  on $ u ! userId'  .=. m ! uid'

  g <- query group

  on $ g ! groupId' .=. m ! gid'

  return $ u >< g

{-
 SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
        T2.group_id AS f3, T2.group_name AS f4
 FROM (EXAMPLE.user T0 INNER JOIN
       EXAMPLE.membership T1
       ON (T0.user_id = T1.uid))
       INNER JOIN EXAMPLE.group T2 ON (T2.group_id = T1.gid)
 -}

レコードの型と SQL の値の並びを対応付けることでこのような機能を実現しています。レコードの型をそのまま利用することで記述がすっきりしました。

それではこちらでも親子関係で結合してみましょう。

user と group の親子結合 - カラム選択

parentAndChildOfUserGroups0
   :: Relation () ((String, String), (String, String))
parentAndChildOfUserGroups0 =  relation $ do
  parent <- query userAndGroup
  child  <- query userAndGroup

  on $ parent ! fst' ! parentId' .=. child ! fst' ! userId'

  return ((parent ! fst' ! userName' ><
           parent ! snd' ! groupName')
          ><
          (child ! fst' ! userName' ><
           child ! snd' ! groupName'))

{-
 SELECT T3.f1 AS f0, T3.f4 AS f1, T7.f1 AS f2, T7.f4 AS f3
 FROM (SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
              T2.group_id AS f3, T2.group_name AS f4
       FROM (EXAMPLE.user T0 INNER JOIN
             EXAMPLE.membership T1
             ON (T0.user_id = T1.uid)) INNER JOIN
            EXAMPLE.group T2 ON (T2.group_id = T1.gid)) T3 INNER JOIN
            (SELECT T4.user_id AS f0, T4.user_name AS f1,
                    T4.parent_id AS f2,
                    T6.group_id AS f3, T6.group_name AS f4
             FROM (EXAMPLE.user T4 INNER JOIN
                   EXAMPLE.membership T5
                   ON (T4.user_id = T5.uid)) INNER JOIN
                  EXAMPLE.group T6
                  ON (T6.group_id = T5.gid)) T7 ON (T3.f2 = T7.f0)
 -}

さきほど定義した userAndGroup をそのまま再利用して結合を行なっています。展開後の SQL 内の名前のつけかえも自動的の行なわれるので気にする必要はありません。またこのように定義した結合を含んだ Relation も、そのまま他の Relation から再利用することができます。
(!) が連なって使われいますが、これは (a, b) がカラム2つのレコードと考えるとわかりやすいです。fst' および snd' をキーとして指定すれば、それぞれ fst側、snd側を選択することができます。この例ではその選択されたレコードからさらにカラムを選択しています。

ところで、Relation の記述内の式は単なる Haskell の式なので、変数にも束縛できます。

user と group の親子結合 - let

parentAndChildOfUserGroups1
   :: Relation () ((String, String), (String, String))
parentAndChildOfUserGroups1 =  relation $ do
  parent <- query userAndGroup
  child  <- query userAndGroup

  let parentUser = parent ! fst'
      childUser  = child  ! fst'

  on $ parentUser ! parentId' .=. childUser ! userId'

  return ((parentUser ! userName' ><
           parent ! snd' ! groupName')
          ><
          (childUser  ! userName' ><
           child  ! snd' ! groupName'))

parentUser と childUser の部分を let で共通化することができました。

SQLは長くなりますが、レコードをそのまま取り出す方が記述は単純になります。

HRR - user と group の親子結合

parentAndChildOfUserGroups :: Relation () ((User, Group), (User, Group))
parentAndChildOfUserGroups =  relation $ do
  parent <- query userAndGroup
  child  <- query userAndGroup

  on $ parent ! fst' ! parentId' .=. child ! fst' ! userId'

  return $ parent >< child

余談ですが、Monad内包表記を使って書くと、より SQL っぽい語順になります。

HRR - user と group の親子結合 - Monad内包表記

parentAndChildOfUserGroupsMC :: Relation () ((User, Group), (User, Group))
parentAndChildOfUserGroupsMC =  relation
  [ parent >< child
    | parent <- query userAndGroup
    , child  <- query userAndGroup

    , () <- on $ parent ! fst' ! parentId' .=. child ! fst' ! userId'
    ]
HRR による結合 - まとめ
  • 定義済みのクエリを再利用してより大きなクエリを構成できる
  • カラムの名前つけかえが不要
  • レコードの型をそのまま扱える
外部結合と直接結合記法 - Outer join and Direct Join style

HRR は外部結合もサポートしています。さきほどのテーブルでグループに参加していないユーザーや参加者のいないグループも全て出力するクエリを考えていみます。

user と group - 外部結合

groupMemberShip :: Relation () (Maybe Membership, Group)
groupMemberShip =  relation $ do
  [ m >< g
    | m  <- queryMaybe membership
    , g  <- query      group
    , () <- on $ m ?! gid' .=. just (g ! groupId')
    ]

userAndGroupAll :: Relation () (Maybe User, Maybe Group)
userAndGroupAll =  relation $ do
  u  <- queryMaybe user
  mg <- queryMaybe groupMemberShip

  let mayM = mg ?!? fst'
  on $ u ?! userId' .=. mayM ?! uid'

  let g    = mg ?! snd'

  return $ u >< g

{-
 SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
        T3.f2 AS f3, T3.f3 AS f4
 FROM EXAMPLE.user T0 FULL JOIN
      (SELECT T1.uid AS f0, T1.gid AS f1,
              T2.group_id AS f2, T2.group_name AS f3
       FROM EXAMPLE.membership T1 RIGHT JOIN
            EXAMPLE.group T2
            ON (T1.gid = T2.group_id)) T3
      ON (T0.user_id = T3.f0)
 -}

外部結合で NULL(Haskell 側では Nothing) を許す側を query ではなく queryMaybe にします。型の中に Maybe が混ざるため、カラム選択や条件式で (?!)、(?!?)、just が出てきて面倒ですが、このようにすることで、結果の型へ Maybe を伝搬させることができます。

ここまでで紹介した結合の記述方法は結合に加えるテーブル式を query あるいは queryMaybe で指定していく方式でした。しかし、この記述方法では、そこまでに組みあげた結合の側に NULL を許すような外部結合を書くことができません。

そこで、直接結合の記法を紹介します。一つ前の Relation を直接結合の記法で書くと以下のようになります。

user と group - 外部結合

userAndGroupAllDirect :: Relation () (Maybe User, Maybe Group)
userAndGroupAllDirect = relation $ do
  umg <- query $
         (user `left` membership
          `on'` [\ u m -> just (u ! userId') .=. m ?! uid' ])
         `full`
         group
         `on'` [ \ um g -> um ?!? snd' ?! gid' .=. g ?! groupId' ]
  let um = umg ! fst'
      u  = um ?! fst'
      g  = umg ! snd'

  return $ u >< g

{-
 SELECT T4.f0 AS f0, T4.f1 AS f1, T4.f2 AS f2, T4.f5 AS f3, T4.f6 AS f4
 FROM (SELECT T2.f0 AS f0, T2.f1 AS f1, T2.f2 AS f2,
              T2.f3 AS f3, T2.f4 AS f4,
              T3.group_id AS f5, T3.group_name AS f6
       FROM (SELECT T0.user_id AS f0, T0.user_name AS f1,
                    T0.parent_id AS f2, T1.uid AS f3, T1.gid AS f4
             FROM EXAMPLE.user T0 LEFT JOIN
                  EXAMPLE.membership T1
                  ON (T0.user_id = T1.uid)) T2 FULL JOIN
                  EXAMPLE.group T3
                  ON (T2.f4 = T3.group_id)) T4
 -}

left、full がそれぞれ LEFT JOIN、FULL JOIN のための2項演算です。on' に条件式を返すラムダ式のリスト渡すことで結合条件を指定します。

様々な型付け

SQL の集約操作と型付け

次は集約操作を行なう例を考えてみましょう。次の SQL はグループのメンバーが 3人以上いるグループを並べ挙げています。

SQL - グループで集約

SELECT gid,
       ('gid 0以外の3人以上のグループ: ' || group_name),
       count (*)
FROM   group INNER JOIN membership ON group_id = gid
WHERE gid <> 0
GROUP BY gid, group_name
HAVING count (*) >= 3

集約を行なっているクエリの場合、SELECT と FROM の間に書く結果のカラムや HAVING 節の条件には集約しているキーと集約関数の式しか書くことができません。WHERE 節の条件に書ける式とは区別する必要があるのです。

HRR では型付けでこの区別を行なうので、誤った式を書かずに済みます。同じ意味を持つクエリを HRR で書けば次のようになります。

グループで集約

memberMoreThanTwo :: Relation () ((Int32, String), Int32)
memberMoreThanTwo = aggregateRelation $ do
  g <- query group
  m <- query membership

  let mgid = m ! gid'
  wheres $ mgid .>. value 0

  aggregatedGid       <- groupBy $ mgid
  aggregatedGroupName <- groupBy $ g ! groupName'

  let mcount = count (mgid)
  having $ mcount .>=. value 3

  return (aggregatedGid                           ><
          value "gid 0以外の3人以上のグループ: "
            .||. aggregatedGroupName              ><
          mcount {- >< u ! uid'  型エラーになる -} )

{-
 SELECT T1.gid AS f0,
        ('gid 0以外の3人以上のグループ: ' || T0.group_name) AS f1,
        COUNT (T1.gid) AS f2
 FROM EXAMPLE.group T0 INNER JOIN
      EXAMPLE.membership T1 ON (0=0)
 WHERE (T1.gid > 0)
 GROUP BY T1.gid, T0.group_name
 HAVING (COUNT (T1.gid) >= 3)
 -}

wheres で WHERE 節に指定する条件式と、having で HAVING節に指定する条件は型が区別されます。この例の場合では集約していないキー (たとえば u ! uid' 等) を間違って結果に含めてしまったり、HAVING 内に書いてしまったりということが起こりません。

また、このように集約を含んだ Relation も別の Relation の定義内から結合等に再利用することができます。

Ordrings

SQL の ORDER BY ... に指定する式においても、集約を行なっていない場合と行なっている場合で書ける式が変わってきます。集約している場合には集約しているキーと集約関数の式しか書くことができません。

HRR はこれについても集約の例で示したのと同様に型を区別することで誤りを防ぎます。

まずは集約していないものの例です。先に定義した userAndGroup に昇順の制約を加えたものが以下のようになります。 asc で昇順の制約とする式を指定しています。

user と group - 名前昇順

userAndGroupAsc :: Relation () (User, Group)
userAndGroupAsc =  relation $ do
  u <- query user
  m <- query membership

  on $ u ! userId'  .=. m ! uid'

  g <- query group

  on $ g ! groupId' .=. m ! gid'

  asc $ g ! groupName'
  asc $ u ! userName'

  return $ u >< g

{-
 SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
        T2.group_id AS f3, T2.group_name AS f4
 FROM (EXAMPLE.user T0 INNER JOIN
       EXAMPLE.membership T1
       ON (T0.user_id = T1.uid))
      INNER JOIN
      EXAMPLE.group T2
      ON (T2.group_id = T1.gid)
 ORDER BY T2.group_name ASC, T0.user_name ASC
 -}

次は集約している場合の例です。先に定義した memberMoreThanTwo に降順の制約を加えたものが以下のようになります。desc で昇順の制約とする式を指定しています。

user と group - 名前昇順

memberMoreThanTwoDesc :: Relation () ((Int32, String), Int32)
memberMoreThanTwoDesc =  aggregateRelation $ do
  g <- query group
  m <- query membership

  let mgid = m ! gid'
  wheres $ mgid .>. value 0

  aggregatedGid       <- groupBy   mgid
  aggregatedGroupName <- groupBy $ g ! groupName'

  let mcount = count (mgid)
  having $ mcount .>=. value 3

  desc mcount

  return (aggregatedGid                          ><
          value "gid 0以外の3人以上のグループ: "
            .||. aggregatedGroupName             ><
          mcount)

{-
 SELECT T1.gid AS f0,
        ('gid 0以外の3人以上のグループ: ' || T0.group_name) AS f1,
        COUNT (T1.gid) AS f2
 FROM EXAMPLE.group T0 INNER JOIN
      EXAMPLE.membership T1
      ON (0=0)
 WHERE (T1.gid > 0)
 GROUP BY T1.gid, T0.group_name
 HAVING (COUNT (T1.gid) >= 3)
 ORDER BY COUNT (T1.gid) DESC
 -}

ここでは desc には集約しているキーと集約関数の式しか指定できないように型が検査されます。

Placeholderの型付けと伝搬 - Typeful Placeholder propagation

placeholder についても型付けをしてみました。まずは例としてグループ名を Placeholder で指定するクエリを考えてみます。

group - place holder でグループ名指定

specifiedGroup :: Relation String Group
specifiedGroup =  relation' $ do
  g <- query group

  (ph, ()) <- placeholder (\ph' -> wheres $ g ! groupName' .=. ph')

  return (ph, g)

{-
 SELECT T0.group_id AS f0, T0.group_name AS f1
 FROM EXAMPLE.group T0
 WHERE (T0.group_name = ?)
 -}

Placeholder を使いたい式を placeholder で囲むと引数に Placeholder を貰うことができます。placeholder の結果は Placeholder の型を運ぶための変数と囲んだ式の結果のペアになります。ここでは wheres の中で Placeholder を使っています。

Placeholder の型を運ぶ変数をクエリの結果とともに relation' に渡すことで Placeholder の型付きの Relation を作ることができます。Placeholder の型は Relation の第一引数なので、ここでは Placeholder の型は String です。

次は Placeholder 付きの Relation を結合に利用する例です。

user と group - place holder でグループ名指定

userAndSpecifiedGroup :: Relation String (User, Group)
userAndSpecifiedGroup =  relation' $ do
  u <- query user
  m <- query membership

  on $ u ! userId'  .=. m ! uid'

  (ph, g) <- query' specifiedGroup

  on $ g ! groupId' .=. m ! gid'

  return (ph, u >< g)

{-
 SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
        T3.f0 AS f3, T3.f1 AS f4
 FROM (EXAMPLE.user T0 INNER JOIN
       EXAMPLE.membership T1
       ON (T0.user_id = T1.uid)) INNER JOIN
      (SELECT T2.group_id AS f0, T2.group_name AS f1
       FROM EXAMPLE.group T2
       WHERE (T2.group_name = ?)) T3
      ON (T3.f0 = T1.gid)
 -}

Placeholder 付きの Relation を結合に加えるときには query' あるいは queryMaybe' を使います。Placeholder を伝搬させるために relation' に渡すのは先程と同様です。

Placeholder が複数ある場合には (><) で融合して返すことができますが、順番には注意が必要です。直接結合の形式で書くと、この融合を同時に行なうことができて誤りを減らせます。

user と group の親子結合 - place holder でグループ名指定

parentAndChildOfuserSpecifiedGroups :: Relation
                                       (String, String)
                                       ((User, Group), (User, Group))
parentAndChildOfuserSpecifiedGroups =  relation' $
  query' (userAndSpecifiedGroup
          `inner'`
          userAndSpecifiedGroup
          `on'`
          [\ parent child -> parent ! fst' ! parentId' .=. child ! fst' ! userId' ])

inner' は 2つの Relation の結合を行なうとともに両側の Placeholder を融合してタプルの型の Phace holder にします。この例では、その融合した Placeholder と結合の結果のペアをそのまま relation' に渡しています。

複合キーと Placeholder - Composite key and Placeholders

HRR では複合キーを表現する値を定義できます。まずは単純な例を示してみましょう。

複合キー (1)

gidAndName :: Pi Group (Int32, String)
gidAndName =  groupId' >< groupName'

userAndGroupComposedKey :: Relation () (User, Group)
userAndGroupComposedKey =  relation $ do
  ug <- query userAndGroup
  wheres $ ug ! snd' ! gidAndName .=. value (1, "Kei Hibino")

  return ug

{-
 SELECT T3.f0 AS f0, T3.f1 AS f1, T3.f2 AS f2, T3.f3 AS f3, T3.f4 AS f4
 FROM (SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
              T2.group_id AS f3, T2.group_name AS f4
       FROM (EXAMPLE.user T0 INNER JOIN
             EXAMPLE.membership T1 ON (T0.user_id = T1.uid)) INNER JOIN
            EXAMPLE.group T2
            ON (T2.group_id = T1.gid)) T3
 WHERE ((T3.f3, T3.f4) = (1, 'Kei Hibino'))
 -}

gidAndName は groupId' と groupName' を (><) で融合した複合キーです。(><) はキーに限って考えれば次のような型で、キーを並べます。タプルの結果を選択することができるキーを作ります。

タプルを選択するキー

(><) :: Pi a b -> Pi a c -> Pi a (b, c)

Pi Group (Int32, String) という型は Group から (Int32, String) を選択するキーであるということを表現しています。複合キーを使うと、ug
! snd' ! gidAndName のように、複数の並んだ値をタプルの型で一度に選択できます。この例では一度に選択した値とタプルの定数値 value (1, "Kei Hibino") の比較をクエリの条件に書いています。

もう少し複雑なキーも作ってみましょう。

複合キー (2)

parentAndGroupName :: Pi ((User, Group), (User, Group)) (Int32, String)
parentAndGroupName =
  fst' <.> fst' <.> userId'
  ><
  snd' <.> snd' <.> groupName'

parentAndChildOfComposedKey :: Relation () ((User, Group), (User, Group))
parentAndChildOfComposedKey =  relation $ do
  pc <- query parentAndChildOfUserGroups
  wheres $ pc ! parentAndGroupName .=. value (1, "Haskell")

  return pc

{-
 SELECT T8.f0 AS f0, T8.f1 AS f1, T8.f2 AS f2, T8.f3 AS f3, T8.f4 AS f4,
        T8.f5 AS f5, T8.f6 AS f6, T8.f7 AS f7, T8.f8 AS f8, T8.f9 AS f9
 FROM (SELECT T3.f0 AS f0, T3.f1 AS f1, T3.f2 AS f2,
              T3.f3 AS f3, T3.f4 AS f4,
              T7.f0 AS f5, T7.f1 AS f6, T7.f2 AS f7,
              T7.f3 AS f8, T7.f4 AS f9
       FROM (SELECT T0.user_id AS f0, T0.user_name AS f1, T0.parent_id AS f2,
                    T2.group_id AS f3, T2.group_name AS f4
             FROM (EXAMPLE.user T0 INNER JOIN
                   EXAMPLE.membership T1
                   ON (T0.user_id = T1.uid)) INNER JOIN
                  EXAMPLE.group T2
                  ON (T2.group_id = T1.gid)) T3 INNER JOIN
                  (SELECT T4.user_id AS f0, T4.user_name AS f1,
                          T4.parent_id AS f2,
                          T6.group_id AS f3, T6.group_name AS f4
                   FROM (EXAMPLE.user T4 INNER JOIN
                         EXAMPLE.membership T5
                         ON (T4.user_id = T5.uid)) INNER JOIN
                        EXAMPLE.group T6
                        ON (T6.group_id = T5.gid)) T7
                        ON (T3.f2 = T7.f0)) T8
 WHERE ((T8.f0, T8.f9) = (1, 'Haskell'))
 -}

parentAndGroupName をよく見てみましょう。(<.>) は次のような型になっていてキーを継ぎ足します。一般的には (r ! k1) ! k2 == r ! (k1 <.> k2) が成り立ちます。

キーの継ぎ足し

(<.>) :: Pi a b -> Pi b c -> Pi a c

最後に継ぎ足したキー同士を (><) で融合しています。結果として、( (User, Group), (User, Group) ) から (Int32, String) を選択するキーが定義されています。parentAndGroupName は ( (User, Group), (User, Group) ) の fst側を親、snd側を子として、親のユーザーID子のグループ名で選択するキーを表現しています。

Placeholder は実は複合した値にも対応しています。

複合した値のPlaceholder

parentUserAndChildGroup :: Relation (Int32, String) ((User, Group), (User, Group))
parentUserAndChildGroup =  relation' $ do
  pc <- query parentAndChildOfUserGroups
  (ph, ()) <- placeholder (\ph' -> wheres $ pc ! parentAndGroupName .=. ph')

  return (ph, pc)

parentAndGroupName で選択した (Int32, String) の型を持つ placeholder を一つで表現できてきます。
この機能を活用することで、placeholder の順番の誤りを減らすことができるでしょう。

対応RDBMS

HRR はテーブル定義を読み取るところのみ、RDBMS に依存しています。現状でテーブル定義読み取りに対応しているデータベースは IBM DB2, PostgreSQL, Microsoft SQL server, SQLite3, Oracle です。 Microsoft SQL server, SQLite3 の対応 (https://github.com/yuga/haskell-relational-record-driver-sqlserver, https://github.com/yuga/haskell-relational-record-driver-sqlite3)は @yuga さんから、Oracle 対応 (https://github.com/amutake/haskell-relational-record-driver-oracle)は @amutake_s さんから contribute をいただきました。ありがとうございます。

まとめ

クエリの記述方法にしぼって、SQL を安全に Composable に組み立てるライブラリ Haskell Relational Record (https://github.com/khibino/haskell-relational-record) を紹介しました。複雑な SQL を書かざるをえない Haskeller の助けになれば幸いです。また問題点や提案、類似ツールの紹介などありましたら、教えていただけると私が喜びます。

*1:VIEW を使えば良いという意見もありそうですが