Edited at

Haskell・Servant+Persistent/Esqueletoで作る実用WebAPI (12) Esqueleto:select(続き)

More than 1 year has passed since last update.


はじめに

前記事で、select関数とselectで使用するwhere関数について扱いました。検索条件が毎回固定である場合はそれでいいのですが、selectを実行する関数等の引数に応じて検索条件を変化させたい場合もあるでしょう。

Haskellでも、if文を使うと「条件によって動作を変える」という実装は可能ですが、「thenとelseは同じ型にしないといけない」という制約があります。そのため「変数の値によってアクションを変える」という実装をする時に、他のプログラミング言語とは違うアプローチが必要な場合があります。

本記事では、まず、select関数で使うWHERE節での条件つきの検索条件を実装について扱います。続いて、where以外の検索条件についても扱います。


条件付きの検索条件


Personのリストを取得する共通関数を実装します。この関数は「PersonId(複数指定可)」から検索することもできれば、「PersonType(複数指定可)」を条件にして検索できるものとします。さらに両方の条件を検索条件に含めることができる、とします。取得した結果はApiPeson型に変換して返します。

この関数の型を下記のように定義したとして話を進めます。

getPerson' :: [PersonId] -> [PersonType] -> SqlPersistM' [ApiPerson]

getPerson pid_list ptype_list = do
-- ここに実装が入る

今回実装したい要件は下記の通りです。

- pid_listが空でなく、ptype_listが空であれば、pid_listを条件にしてselectする

- pid_listが空で、ptype_listが空でなければ、ptype_listを条件にしてselectする

- pid_listとptype_listがともに空でなければ、pid_listとptype_listを条件にしてselectする

- pid_listとptype_listがともに空であれば、条件なしでselectする

以上の条件に加えて、例題として「personテーブルのageがNullでないこと」を必須条件とします。

「pid_listが空かどうか」という条件と「ptype_listが空かどうか」という条件の組み合わせで、それぞれselectを個別に実装することもできますが、同じような実装を複数書くことになり、保守性が下がります。狙いは「whereの中に条件文をどう入れ込むか」となります。

今回の話題も、他のプログラミング言語ではあまり苦労しないような話ではあります。


条件付き検索・if文を使うパターン

先に「personテーブルのageがNullでないこと」という条件を含めたselectを書きます。これは前記事で書いたものと同じです。

-- ageがNullでないレコードをSELECT(再掲)

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
return p

これに「pid_listが空でなければ(=指定があれば)、whereの行に『p ^. PersonId `in_` valList pid_list』を足す」としたいところです。「空でなければ」はリストの空判定の「null関数」とnotを組み合わせればいいので、下記のようにしてみたいところですが、これではコンパイルが通りません。

-- ageがNullでないレコードをSELECT(再掲)

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
-- ここ↓でコンパイルエラー
(if not (null pid_list) then (&&. p ^. PersonId `in_` valList pid_list))
return p

他のプログラミング言語と違い、Haskellのif文はelseを省略できません。省略できないどころか、else節はthen節は同じ型でなければいけません。型を合わせるために、検索条件を指定しない場合は「val True」をいれてやることで、コンパイルが通るようになります。

-- ageがNullでないレコードをSELECT(再掲)

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
-- これならOK(pid_listが空ならval Trueを、空でないなら検索条件を追加する)
&&. (if null pid_list then val True else p ^. PersonId `in_` valList pid_list)
return p

ptype_listについても同じことをやればいいので、ptype_listも含めると下記のようになります。「&&.」の行のインデントはwhere_よりは下げる必要があります。

-- ageがNullでないレコードをSELECT(再掲)

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
-- pid_listによる条件
&&. (if null pid_list then val True else p ^. PersonId `in_` valList pid_list)
-- ptype_listによる条件
&&. (if null ptype_list then val True else p ^. PersonType `in_` valList ptype_list)
return p

pid_listとptype_listがともに空でない場合、発行されるSQLは下記のようになります。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE (`person`.`age` IS NULL) AND ((`person`.`id` IN (?, ?, ?)) AND (`person`.`type` IN (?)))
; [PersistInt64 1,PersistInt64 2,PersistInt64 5,PersistText "PersonTypeUser"]


条件付き検索・ヘルパー関数を実装するパターン

上記のやり方だと、pid_listやptype_listが空の場合、下記のように生成されるSQLに「TRUE」が含まれます。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE (`person`.`age` IS NULL) AND (? AND ?)
; [PersistBool True,PersistBool True]

これでも実害はないのですが、できれば「検索条件に含めない場合は、TRUE含めて何も入らない」となった方がSQLのチェックも楽になりますし、さらにHaskellコードももうちょっとすっきりできた方が、とも思います。

この記事を書くまでは思いつかなかったのですが、記事を書くにあたって、下記のようなヘルパー関数を定義して使ってあげると、すっきりする上に余計なTRUEも入らない、と思いつきました。

-- 条件によって「&&. 条件」を追加するヘルパー関数

(&&=.) :: Esqueleto query expr backend => expr (Value Bool) -> (Bool, expr (Value Bool)) -> expr (Value Bool)
(&&=.) src_cond (cond, append_cond) = if cond
then src_cond &&. append_cond
else src_cond

ここで作成した演算子「&&=.」の引数には「(条件, 追加する検索条件)」というタプルを指定します。

-- 「&&.」を使った実装例

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
-- pid_listによる条件
&&=. (not . null $ pid_list, p ^. PersonId `in_` valList pid_list)
-- ptype_listによる条件
&&=. (not . null $ ptype_list, p ^. PersonType `in_` valList ptype_list)
return p

if文を使ったパターンよりは若干すっきりしました。生成されるSQLもすっきりしています。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE `person`.`age` IS NULL
; []

「not null」という条件をよく使う場合には、そこまでひっくるめてヘルパー関数にしてあげればいいでしょう。

-- 引数のリストが空でない場合に検索条件を追加するヘルパー関数

ifNotNull :: Esqueleto query expr backend => expr (Value Bool) -> ([a], expr (Value Bool)) -> expr (Value Bool)
ifNotNull src_cond (list, append_cond) = (&&=.) src_cond (not . null $ list, append_cond)

この「ifNotNull」を使うと、Haskellのコードは下記のようになります。生成されるSQLは「&&=.」を使った場合と変わりません。

-- ifNotNullを使った実装例

select $ from $ \p -> do
where_ $ E.isNothing (p ^. PersonAge)
-- pid_listによる条件
`ifNotNull` (pid_list, p ^. PersonId `in_` valList pid_list)
-- ptype_listによる条件
`ifNotNull` (ptype_list, p ^. PersonType `in_` valList ptype_list)
return p

ここで定義してきた「&&=.」や「ifNotNull」を使う場合、最初の検索条件(例でいうと「E.isNothing (p ^. PersonAge)」)については、「無条件で適用するもの」である必要があります。無条件で適用する検索条件がひとつもない場合には「val True」を入れておけばOKです(下記の例)。この例では、生成されるSQLには必ず「TRUE」が含まれてしまいますが、これはしょうがないです。

-- 無条件で適用する検索条件がないケースの実装例

select $ from $ \p -> do
where_ $ val True
-- pid_listによる条件
`ifNotNull` (pid_list, p ^. PersonId `in_` valList pid_list)
-- ptype_listによる条件
`ifNotNull` (ptype_list, p ^. PersonType `in_` valList ptype_list)
return p


WHERE以外の検索条件設定

select関数で使用する、where以外の関数の一部を紹介します。ここに載っていないものは、HackageのEsqueletoを見ましょう。

では、簡単な方からいきます。


LIMIT

検索時の最大件数を指定します。難しいポイントは何もありませんが、インデントだけは注意しましょう(LIMITに限らず、以下全て)。where_と同じレベルです(間違えると、コンパイルエラーとなります)。

-- LIMIT使用例

eplist <- select $ from $ \p -> do
where_ $ p ^. PersonType ==. val PersonTypeUser
limit 2
return p

生成されるSQLもそのまんまです。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE `person`.`type` = ?
LIMIT 2; [PersistText "PersonTypeUser"]


ORDER BY

昇順/降順を指定する関数「asc/desc」を使用し、さらに対象のテーブル・カラムをwhereの時と同じフォーマットで指定します。使い方は例をみればわかるかと思います。orderByの引数はリストですので、ソート条件は複数指定できます。複数指定のケースについては2つ目の例を見てください。

  eplist <- select $ from $ \p -> do

where_ $ p ^. PersonType ==. val PersonTypeUser
-- personテーブルのカラムnameで昇順(asc)ソートする
orderBy [asc $ p ^. PersonName]
return p

生成されるSQLは下記の通りです。これもそのまんまですね。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE `person`.`type` = ?
ORDER BY `person`.`name` ASC
; [PersistText "PersonTypeUser"]

NULL可のカラムも指定できます。該当のカラムがNULLのレコードがある場合のソート結果については、HaskellやEsqueletoというよりもRDBの問題です。

    eplist <- select $ from $ \p -> do

where_ $ p ^. PersonType ==. val PersonTypeUser
-- personテーブルのカラムnameで昇順(asc)、カラムageで降順(desc)の順でソートする
orderBy [asc $ p ^. PersonName, desc $ p ^. PersonAge]
return p

生成されるSQLです。Haskellで実装した通りのSQLになっています。

[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`

FROM `person`
WHERE `person`.`type` = ?
ORDER BY `person`.`name` ASC, `person`.`age` DESC
; [PersistText "PersonTypeUser"]


GROUP BY

最後はGROUP BYです。まず「どのカラムでグループ化するか」をgroupBy関数を使って指定します。カラムは1つでも複数でも大丈夫ですが形式が変わります。ここではカラムが1つの場合の例を書きます。次にreturnでの戻り値がこれまでの例とは異なります。そのため、selectの戻り値もEntityではなく、(Value, count)のタプルになります。

  res <- select $ from $ \p -> do

-- personテーブルのカラムtypeでグループ化する
groupBy $ p ^. PersonType
-- カラムtypeに対する件数(count)を返す
return (p ^. PersonType, countRows)

-- 得られたリストの各要素を処理する
forM_ res $ \(Value ptype, Value count') -> do
-- countの型を確定させるために、ここで指定する
liftIO $ print $ "PersonType = " ++ show ptype ++ ", count = " ++ show (count' ::Int)

この例では、得られたカラムと件数をprintするために「forM_」でループを回していますが、単純なデータ処理の場合には、forM_ではなく、fmap等を使うことになるでしょう。Entityの場合と同様、countの戻り値も「Value型」となっていますので、上記の例のようにコンストラクタを使って中身を取り出すか、unValue関数を使って抜き出すか、をする必要があります。

この例で生成されるSQLは下記の通りです。SQL自体はシンプルです。

[Debug#SQL] SELECT `person`.`type`, COUNT(*)

FROM `person`
GROUP BY `person`.`type`
; []

上記のコードでprintにより表示された結果は下記の通りです。

"PersonType = PersonTypeUser, count = 1"

グループ化するカラムが複数の場合には、groupByの引数にタプルを指定します。それにともないreturnの中身も変わっています。groupByの形式とreturnの形式は揃っていなければいけません。

  res <- select $ from $ \p -> do

-- 複数カラムのグループ化指定。引数をタプルにする
groupBy $ (p ^. PersonType, p ^. PersonAge)
return (p ^. PersonType, p ^. PersonAge, countRows)

この場合に生成されるSQLは下記の通りです。

[Debug#SQL] SELECT `person`.`type`, `person`.`age`, COUNT(*)

FROM `person`
GROUP BY `person`.`type`, `person`.`age`
; []


まとめ

JOINを使わない範囲でselectの使い方を見てきました。前回と今回でEsqueletoでの使い方のイメージがぼちぼちできてでたでしょうか。ここで紹介していない関数もまだまだ多いのですが、SQLを理解していれば、Esqueletoのドキュメントをみるだけで使い方はだいたいわかるのではないかと思います。

次回からは(個人的にEsqueletoでの最大のヤマと思っている)JOINを使ったselectの話です。