かなり久しぶりにQiitaアドベントカレンダー(Haskell Advent Calendar 2025)向けに記事を書いてみました。
はじめに
HaskellのORMライブラリの1つであるEsqueleto。Persistentをベースとしつつ、PerisstentではサポートされていないSelect系SQLを生成できる、という「Persistentの拡張ライブラリ」と呼べるような位置づけです。ORMにもいろいろありますが、Esqueletoは「SQLライクなEDSL」となっていますので、どんなSQLが生成されるかわからない、といった悩みはほぼ無縁です。数テーブルを用いた複雑なJOINではSQLビルダの挙動がわかりづらくなるケースもありましたが、それも本稿のテーマである「Esqueleto.Experimental(以後、単に『Experimental』とします)」にて解消されました。
Experimentalが最初にリリースされたのが2020年5月の「3.3.3.0」ということで、執筆時点で5年以上経過しています。今更ではありますが、「Experimentalってどういうもの?」という記事はほとんど見かけないので書いてみた、というところです。
Experimentalが出るまでのものは「Legacy」という名前に変わりました。3.3.3.0以降では、インポートするモジュール名にLegacyをつけるだけで、これまでのコードもだいたいは動いてはくれるのですが、「全て動く」とも言い切れない状況だったりします。Experimentalに移行するとしても、ほぼ機械的な修正でいけるので、マイグレーションする価値は大いにあります。もちろん、新規に書く場合は最初からExperimentalでいったほうがよいです。Experimental、という表記自体もなくなってもいいのに、とも思いますが、もしかしたらこのままずっとExperimentalかもしれません。
なお、以前に書いたいくつかの記事も修正をした方がよい状況ですので、それはそれで追々やっていこうと思ってます。
Experimentalに乗り換えた経緯
私はRDBを使用するAPIサーバを実装するのにHaskell+Servant+Esquqletoを利用しているのですが、開発環境(MacOS)での制約上、これまでのGHC8.6系から少なくとも8.10に上げたくなりました。プロジェクトビルドにStackを使用しているため、GHCのバージョンを上げた時点でEsqueletoのバージョンも上げざるを得なくなりました。
Esqueleto-3.3.1.1にあげつつ、インポートするモジュールをEsqueleto.Legacyに変更していくだけでだいたいは動いてくれましたが、Left Outer Joinを多様したSQLが実行時エラーを吐くようになりました。
原因を詳しく探っていくと、どうやらEsqueletoが3.3系に上がる前の3.2.0にて、Join時のON節の記述順の制約に変更があったようです。本気を出せばちゃんと実行できるように修正もできるかとも思ったのですが、せっかくの機会なのでLegacyからExperimentalに乗り換えてみたのでした。
Experimentalによる変更1:テーブル名を明示的に指定
Legacyでは、select文にてテーブル名を指定するための要素はなく、型推論によってテーブルが1つに限定されていました(1つに絞れない場合はコンパイルエラー)。下記の例では、accountテーブルのnameカラムを使用している、と推測され、その結果accountテーブルにアクセスするSQLが生成されます。本稿では、SQLにアクセスできるために必要な準備については省略します(別記事をご参照ください)
-- Legacyサンプル
import Database.Esqueleto.Legacy
-- 中略。何かの関数の中の実装
account_list <- select $ from $ \a -> do
where_ $ a ^. AccountName ==. val "Account-1"
pure a
liftIO $ print account_list
Experimenalでは、以下のようになります
-- Experimentalサンプル
{-# LANGUAGE TypeApplications #-} -- Experimentalでは必須
import Database.Esqueleto.Experimental
-- 中略。何かの関数の中の実装
account_list <- select $ do -- ここと
a <- from $ table @Account -- ここが変わる
where_ $ a ^. AccountName ==. val "Account-1"
pure a
liftIO $ print account_list
Legacyではfrom以下がラムダ式になっていたのですが、Experimentalでは「select以下がdo構文(つまりアクションの記述)」となり、「ラムダ式の仮引数相当がfromとtableによって指定される」となります。さらに、このtableの引数として、テーブル名(相当)の指定が必須となります。Legacyでの「型推論によるテーブル名決定」で困ることは、経験的にはなかったですが、おそらくJOINの記述の改善のためにこれが必要、となったのでしょう。
なお、この変更のためにTypeApplications言語拡張が必要となります。
Experimentalによる変更2:JOIN構文が大きく変更
まずは、シンプルな形での変更内容を書いてみます。LegacyでのINNER JOINはこんな感じですね。
-- LegacyでのINNER JOINサンプル
box_list <- select $ from $ \(b `InnerJoin` a) -> do
on $ b ^. BoxAccountId ==. a ^. AccountId
where_ $ a ^. AccountName ==. val "Account-1"
pure (a, b)
JOINするテーブルを指す仮引数がInnerJoinというコンストラクタによって紐づけられています。2テーブルJOINの場合は、変数2つでON節が1つと、特に悩む要素はないのですが、3テーブル、4テーブルと増えていった場合、Esqueleto-3.2.0未満であれば「変数の逆順にON節を書く」という記述をする必要がありました。ON節の順番は逆順でなくてもコンパイルは通りますが、実行時に不正なSQLが生成され、RDBアクセスでのエラーとなります(squeleto-3.2.0以降はON節の制約が変わったようですが、詳しい話までは確認してません)。また、テーブル数が増えただけでなく、「どのテーブルからJOINするか」を変更するためにJOINの結合時に括弧を使った場合、「どの順番にON節を書けばいいのか」がなかなかわかりにくかったりします(実際には実行してみてのトライ&エラーに頼ることもありました)。
対して、Experimentalではこうなります。
-- ExperimentalでのINNER JOINサンプル
box_list <- select $ do
b :& a <- from $ table @Box
`innerJoin` table @Account
`on (\(b :& a) -> b ^. BoxAccountId ==. a ^. AccountId)
where_ $ a ^. AccountName ==. val "Account-1"
pure (a, b)
innerJoinもアクションとして記述する形となります。ON節が「どのテーブルかを(型推論ではなく、引数の数や順番を用いて)指定できるラムダ式」という形式になりました。冒頭に書いた「SQLライク」というテイストからは離れてしまいましたが、記述上のメリットを重視した結果、なのでしょう(多分ね)。
この例だけでは「単純にスタイルがちょっと変わっただけ」と思うかもしれません。Experimentalが真価を発揮するのは、上述した3テーブル以上の複雑なJOINのケースとなります。
もともと、SQLにおけるJOINは、「ツリー構造」での表現と対応しています。例えばテーブルA,B,C,DをJOINする場合に、((A+B)+C)+D、という結合もあれば、A+(B+(C+D))や(A+B)+(C+D)といった結合方法もあります。SQLとして記述する限り、いずれかの結合に分類されます。Experimentalでは、結合する順番に応じて、その順番を型として明示的に記述する方式に変更されました。そのため「コンパイルが通ったのに実行時エラーが起きる」というケースがLegacyよりも格段に減ったのではないかと思います。
Experimentalによる変更3:CROSS JOINがサポートされた
CROSS JOINは「テーブルAとBのそれぞれのレコードの総当りを返す」というような説明になるかもしれませんが、個人的には、例えば「group_id毎にdateが最大のレコードを抽出したい」というような場合にとても有用です。Legacyの範疇だとなかなか効率的なSQLが書きづらかったのですが、CROSS JOINを使うとすっきりと記述できます。これは、LegacyからExperimetalに移行しようと思った大きめの理由の1つとなっています。
サンプルを書くとなるとちょっと大掛かりになりそうなので、また別記事にて触れてみたいと思います。
まとめ
というわけで、Esqueleto.Experimentalの紹介を軽く(?)してみました。LegacyからExperimentalへの変更は、「型システムに基づくORM」の良さをさらに引き出すものではないかと思っています。
複雑なJOINを実装する場合は、相応の記述をしないといけないわけで、学習コストがちょっと(?)高いといえば高い要素もあります。以前は、型があってないときのコンパイルエラーが起こったら、エラーメッセージを見ながら自力でどうにかするしかなかったわけですが、最近のAIはすごいですね。コードとエラーメッセージを貼り付けると、「なぜエラーになるか」も教えてくれるし「どう直せばいいか」も教えてくれます。
もちろん、提示された回答が「自分が本当にやりたいことか」の確認は必要ですが、Haskellでの「コンパイルが通ればだいたい期待した通りに動く」を考えると、回答の精査はそんなに神経質にならなくてもよいかも、とも思います。