はじめに
WebAPIとしての最低限の枠組みができてきたので、このあたりでRDBアクセスの本命である「Esqueleto」に話題を移します。WebAPI自体の話題もまだ残りがあるのですが、それはEsqueletoの話を一通りした後に再開します。ここからしばらく続くEsqueletoの記事は、ほぼ純粋に「RDBアクセスだけ」に限った話でありWebAPIとは独立といえば独立です。ですがDBを使ったWebAPIの実装の大半がこのRDBアクセスとなるため、Esqueletoに集中して記事を書きます。Esqueletoは型安全の仕組みがすごい反面、使い方に慣れるにはそれなりのハードルもあるため、丁寧に書いていこうと思います。
Esqueletoの記事はの1本目は「selectの基本」となります。select系の記事はJoinの話も含めると全部で5本くらいになりますが、今回はJoinを含まない、selectそのものの話とselectの結果取得できるEntity型の話となります。
select関数の基本的な使い方
本記事のメインテーマのselectです。Esqueletoの紹介記事でも書きましたが、基本的な作りはこんなところになります。runSql等は省略しています。
-- selectした結果が「eplist」に入る
eplist <- select $ from $ \p -> do -- select, from, 関数(doブロック含む)でワンセット
-- join, on, where_, limit, orderByなど
return p -- returnで閉じる
これも以前の記事に書いたのですが、Esqueletoのselectには「SELECTする対象のテーブルを指定」する引数等はありません。上記の「join, on...」と書いたところで推定される型により、テーブルが決まります。そのため、何も条件を書かなければ「型が決まらない」というコンパイルエラーになります。
-- これだけだとコンパイルエラーになる
eplist <- select $ from $ \p -> do
return p
もしpersonテーブルの全レコードを取得したいのであれば、SQLとしては「SELECT * FROM person」となりますよね。そういうSQLを発行したい場面もあるでしょうから、その方法について説明します。要は上記のコードのp、あるいは戻り値が入る「eplist(名前はEntity Personのリストという意図です)」の型がPersonに紐付けばいいわけです。後者のアプローチでいくと、eplistの型は「[Entity Person]」になりますから、それを型指定します。関数の内部で使う変数の型指定をする場合はGHC拡張「ScopedTypeVariables」が必要なので、ソースの冒頭に入れましょう。
-- 必要なGHC拡張
{-# LANGUAGE ScopedTypeVariables #-}
eplist::[Entity Person] <- select $ from $ \p -> do
return p
発行されるSQLは下記のようになります。SQLのデバッグ出力については、別の記事で記載します。Persistent、Esqueletoともに「SELECT * FROM..」のようなアスタリスクは使われず、SELECTの後ろには全てカラムが指定されます。
[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`
FROM `person`
現実的には何かしらの「whereでの条件指定」をすることになるでしょう。where_を追加すると、型が決まりますので、戻り値での型指定は不要です。こんな感じですね。where_の後ろの$を忘れると、型があわずにコンパイルエラーとなります。慣れないうちは忘れがちなので注意しましょう。
eplist <- select $ from $ \p -> do
where_ $ p ^. PersonName ==. val "Tom"
return p
ここでも書きますが、「where」はHaskellでの予約語のため、Esquletoの関数としては「where_」となっています。
発行されるSQLは下記になります。SQL文のWHERE節で「?」の部分には、最下行の「"Tom"」が入ります。このように、SQLインジェクション対策はPersistent/Esqueleto側でやってくれるので、Haskell実装としての対策は不要です。
[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`
FROM `person`
WHERE `person`.`name` = ?
; [PersistText "Tom"]
WHERE節の書き方
Esqueletoでの最初のハードルが、「どうやってSQLでのWHERE節を書けるようになるか」です。理屈がどうというより、例をいっぱい見て慣れるしかないかと思います。
記述法の考え方は下記の通りです。発行したいSQLのWHERE節が「WHERE テーブル名.カラム名 = 値」であるとします。
- 「テーブル名.カラム名」の部分は、fromの後ろの関数の仮引数pを使って
p ^. テーブル名カラム名
となる。「^.」はEsqueletoで用意されている演算子 - 「=」の部分は「==.」とする。これもEsqueletoで用意されている演算子
- 「値」の部分は、即値(文字列であったり数値であったり)の場合は「val」をつける。これはEsqueletoで用意されている関数
説明としてはこんな感じになりますが、それよりもwhere_ $ p ^. PersonName ==. val "Tom"
という見方に慣れましょう。SQLが「WHERE person.id = pid(←便宜的に変数で書きます)」であれば、Esqueletoではwhere_ $ p ^. PersonId ==. val pid
です(pidはPersonId型とします)。最初のうちによくやるミスは「==.のピリオドを忘れる」「valを付け忘れる」です。どちらをやってもビルドエラーになります。ビルドエラーが出たら、こういったミスがないかを確認しましょう。
select戻り値の扱い方
select関数の戻り値は「[Entity a]型」になります。上の例なら「[Entity Person]型」ですね。Entity PersonはPersonIdとPersonをペアにしたものです。ので、それぞれを使いたい場合には下記の要領で分解します。
PersonIdのみ取得したい
Entity Person型の値からIDを取り出す関数はentityKey関数です。下の例では、「ep」にEntity Person型の値があるとしています。繰り返しますが、selectの戻り値はEntity aの「リスト」です。リストのままIDを抜き出すのであれば、fmapの演算子「<$>」を使って、このようになりますね。リストのまま処理をしているので、取得した型もPersonIdの「リスト」となります。
-- EntityからIDを取得する。pidの型はPersonId型となる
let pid = entityKey ep
-- EntityのリストからIDを取得する。pid_listの型は[PersonId]型となる
let pid_list = entityKey <$> eplist
Personのみ取得したい
Entity Person型の値からPerson型の値を取得するのはentityVal関数になります。使い方はentityKeyと同じです。
-- Entityから値を取得する。pの型はPerson型となる
let p = entityVal ep
-- Entityのリストから値のリストを取得する。p_listの型は[Person]型となる
let p_list = entityVal <$> eplist
PersonIdとPersonを両方取得したい
Entity Person型の値からIDと値の両方を一度に取得したいのであれば、コンストラクタを使う方法もあります。Entity Personの「リスト」で同じことをするには、ちょっと工夫が必要のなので、ここでは省略します(話が長くなってしまうため)。
-- EntityからID,値を取得する。pidの型はPersonId型に、pの型はPerson型となる
let Entity pid p = ep
APIの戻り値にするには
先の記事で定義した「toApiPersonFE関数」は、Entity Person型をApiPerson型に変換する関数ですので、selectの戻り値を直接ApiPerson型に変換することができます。
-- api_person_listは[ApiPerson]型
let api_person_list = toApiPersonFE <$> eplist
WHERE節の書き方(その2)
ここでは、WHERE節で書きがちな条件をいくつか紹介します。
IN
SQLでいう「WHERE person.id IN (1,2,5)」というような書き方です。Esqueletoでは「in_関数」を使います。inもHaskellの予約語なので、「in_」となっています。
使い方は、さっきの例の「==.」のかわりに「`in_`」を使います。関数を演算子のように使用するとSQLっぽく見えるようになるため、in_をバッククォートでくくっています。INの後ろに即値が来る場合、valではなくリスト用の「valList」を使います。
-- デモ用にPersonIdのリストを作成
let pid_list::[PersonId] = toSqlKey <$> [1, 2, 5]
eplist <- select $ from $ \p -> do
where_ $ p ^. PersonId `in_` valList pid_list -- in_とvalListの使い方に注目
return p
発行されるSQLは下記の通りです。SQLではINになっていますね。
[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`
FROM `person`
WHERE `person`.`id` IN (?, ?, ?)
; [PersistInt64 1,PersistInt64 2,PersistInt64 5]
IS NULL
SQLでも「あるカラムがNULLであるレコードを検索する場合には『WHERE カラム名 IS NULL』としないといけない(「WHERE カラム名 = NULL」はアウト)」というハマリ(?)がありますが、Esqueletoには「IS NULL」に該当するisNothing関数があります。isNothing関数はData.Maybeモジュールにも同名の関数があり、名前かぶりになりがちので、Esqueletoであることを明示する方がいいでしょう。なお、Maybe型となっていないカラムに対して「isNothing」をしようとすると、型があわずにコンパイルエラーとなります。安心ですね。
import Database.Esqueleto as E
eplist <- select $ from $ \p -> do
-- person.age IS NULL 相当
where_ $ E.isNothing (p ^. PersonAge) -- Esqueletoのプレフィックス「E.」をつける
return p
発行されるSQLは以下の通りです。ちゃんと「IS NULL」なってます。
[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`
FROM `person`
WHERE `person`.`age` IS NULL
複数条件の場合
WHERE節に複数の条件を記述する場合があります。Esqueletoでは複数の条件を「&&.」で繋げばOKです。「&&.」は見ての通り「AND」の意味になります。「OR」にしたければ「||.」になります。例では「&&.」を使います。
let pid_list::[PersonId] = toSqlKey <$> [1, 2, 5]
eplist <- select $ from $ \p -> do
-- person.id IN (1,2,5) AND person.name = "Tom" 相当
where_ $ p ^. PersonId `in_` valList pid_list &&. p ^. PersonName ==. val "Tom"
return p
Esqueletoに限らない話ですが、演算子の優先度は関数適用よりも低いため、&&.の前後を括弧でくくる必要はありません。&&.もうっかりピリオドを忘れがちなので注意しましょう。
発行されるSQLは下記の通りです。シンプルにANDが使われています。
[Debug#SQL] SELECT `person`.`id`, `person`.`name`, `person`.`age`, `person`.`type`
FROM `person`
WHERE (`person`.`id` IN (?, ?, ?)) AND (`person`.`name` = ?)
; [PersistInt64 1,PersistInt64 2,PersistInt64 5,PersistText "Tom"]
NOTイコール
Haskellでは「==」の反対は「/=」です。EsqueletoではSQLに合わせて「!=.」となっています。それだけです(例は不要でしょう)
まとめ
最初のうちは、コンパイルエラーが出ても「何が悪いのか」がよくわからないことが多いのですが、慣れてくると不思議なもので、エラーが出ても、ものの数秒でミスがわかるようになります。やはり慣れです。本記事の範囲だけだと、ごく簡単なSQLしか扱えないのですが、それでも型安全のため、例えば「PersonIdを使って(間違って)違うテーブルをselectする」というようなミスは起きなくなります(そのようなコードを書いたらコンパイルエラーになるため)。
次回はORDER BY、LIMIT、GROUP BY等を中心に扱います。