家計簿アプリを作る:HaskellでSQL編
自分用に Haskell で家計簿アプリ的なのを作り始めました. 今回はまずバックエンドでのDBとの繋ぎの部分のメモ書きです.
バックエンドの構成
- バックエンドには Servant を使う(今回はあまり関係ない)
- DB には SQLite を(とりあえず)使う
- 両者のつなぎには Persistent/Esqueleto を使う
Persistent はいわゆるORマッパーのようなライブラリで,型安全にDBを扱う方法を提供してくれる. しかし,JOIN
のような SQL 特有の機能は提供しておらず,そういうのを利用するのに Esqueleto を使う.
個人利用なので規模的にわざわざ RDB を使う必要はないのだが,このアプリケーションは Haskell のサンドボックスも兼ねてるので,無駄にガチガチな構成を利用することにした.
扱うデータ構造
自分用なので,まずはシンプルに出費やらを記録する「Expense」というデータ構造と,それをグループ分けする用の「Label」を用意:
-- extensible を使っています
import Data.Extensible
type ExpendId = Int64
type Expense = Record
"amount" >: Int -- 円
'[ "date" >: Day
, "description" >: Text
, "labels" >: Set LabelId
,
]
type LabelId = Int64
type Label = Record
"name" >: Text
'[ "description" >: Text
, ]
RDB側のデータ構造
これとは別に RDB 用のデータ構造を Persistent で定義する:
import Database.Persist.TH
"migrateAll"] [persistLowerCase|
share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate
ExpenseData
amount Int
date UTCTime
description Text
created UTCTime default=CURRENT_TIME
updated UTCTime default=CURRENT_TIME
deriving Show
LabelData
name Text
description Text
deriving Show
ExpenseLabelRel
expenseId ExpenseDataId
labelId LabelDataId
deriving Show |]
Persistent と extensible のレコードをいい感じに Template Haskell で繋ぐ方法はよくわからないので,愚直に2つ定義するようにしている. Persistent のデータから extensible のレコードへ変換する関数を定義する:
toEpense :: ExpenseData -> Set LabelId -> Expense
ExpenseData amount date description _ _) ls
toEpense (= #amount @= amount
<: #date @= utctDay date
<: #description @= description
<: #labels @= ls
<: nil
toLabel :: LabelData -> Label
LabelData name description)
toLabel (= #name @= name
<: #description @= description
<: nil
DB操作を定義
参照・挿入・削除をとりあえず定義する.
Label の操作
まずは全ての Label
を返すだけの関数を定義する:
import Database.Esqueleto.Experimental hiding (set, (^.))
import qualified Database.Esqueleto.Experimental as DB
import qualified Mix.Plugin.Persist.Sqlite as MixDB
type SQLitable m env =
MixDB.HasSqliteConfig env, HasLogFunc env, MonadReader env m, MonadUnliftIO m)
(
selectLabelAll :: SQLitable m env => m (Map LabelId Label)
= MixDB.run $ do
selectLabelAll <- select $ from $ Table @LabelData
labels pure $ Map.fromList (liftA2 (,) (fromSqlKey . entityKey) (toLabel . entityVal) <$> labels)
自分は rio を愛用しており,それを拡張した mix.hs という自作の簡易フレームワークを利用している. そのため,基本的には RIO Env a
という型を利用すれば,副作用のある処理(ログとか)はだいたい書けるのだが,テストがしやすいように敢えて細かい制約を記述しておく. その制約を ConstraintKinds
拡張を利用してエイリアスしたのが SQLitable
だ(名前が雑).
Esqueleto は現在(バージョン3.4.2),SQL の書き方を刷新している最中っぽく,新しい記法は Database.Esqueleto.Experimental
で利用できる. 今まではラムダ式を利用して FROM
の部分をこう書いていてた:
$
select $ \people -> do
from ^. PersonName ==. val "John")
where_ (people pure people
のに対して,新しい記法では TypeApplications
を利用してこう書く:
$ do
select <- from $ Table @Person
people ^. PersonName ==. val "John")
where_ (people pure people
経緯などについてはあまり詳しく追ってないがこのPRから辿れそう.
Label
の挿入,ID を指定しての参照・削除も簡単なのでさくっと定義:
findLabelById :: SQLitable m env => LabelId -> m (Maybe Label)
=
findLabelById idx $ fmap toLabel <$> get (toSqlKey idx)
MixDB.run
insertLabel :: SQLitable m env => Label -> m LabelId
=
insertLabel label $ fromSqlKey <$> insert (LabelData (label ^. #name) (label ^. #description))
MixDB.run
deleteLabelById :: SQLitable m env => LabelId -> m ()
=
deleteLabelById idx $ deleteKey (toSqlKey idx :: Key LabelData) MixDB.run
Expense の操作
次に Expense
の参照を定義する:
findExpenseById :: SQLitable m env => ExpenseId -> m (Maybe Expense)
= MixDB.run $ do
findExpenseById idx <- get $ toSqlKey idx
expense $ \e -> do -- for :: Maybe a -> (a -> m b) -> m (Maybe b)
for expense <- select $ do
lids <- from $ Table @ExpenseLabelRel
el DB.^. ExpenseLabelRelExpenseId) ==. val (toSqlKey idx))
where_ ((el pure (el DB.^. ExpenseLabelRelLabelId)
pure $ toEpense e (Set.fromList $ fromSqlKey . unValue <$> lids)
Expense
と Label
の関係は ExpenseLabel
で定義しているので,それも引っ張ってくる(もっと賢い SQL があるかもだがお気になさらず). ちなみに DB.^.
としているのは,rio でインポートされる lens の (^.)
とバッティングするためだ.
挿入時には逆に ExpenseLabel
も一緒に挿入するようにする:
insertExpense :: SQLitable m env => Expense -> m ExpenseId
= MixDB.run $ do
insertExpense expense <- insert expenseData
expenseId $ ExpenseLabelRel expenseId . toSqlKey <$> Set.toList (expense ^. #labels)
insertMany_ pure $ fromSqlKey expenseId
where
= ExpenseData
expenseData ^. #amount)
(expense UTCTime (expense ^. #date) 0)
(^. #description)
(expense -- default で初期化されるがなんか値を与える必要があるっぽい?
zeroTime
zeroTime= UTCTime (ModifiedJulianDay 0) 0 zeroTime
insertMany_
を利用することでひとつのクエリで一気に挿入をしてくれる. ちなみに,ID のリストが返ってくる insertMany
は,SQLite の場合はひとつのクエリではなく insert
を mapM
しているだけなので注意.
もちろん,削除の場合も ExpenseLabel
を一緒に削除する:
deleteExpenseById :: SQLitable m env => ExpenseId -> m ()
=
deleteExpenseById idx $ deleteCascade (toSqlKey idx :: Key ExpenseData) MixDB.run
deleteCascade
を使うことで関連するデータも全て削除してくれる(ON DELETE CASCADE
).
最後に年月を指定して Expense
を取得する関数を定義する:
selectExpensesByMonth :: SQLitable m env => (Integer, Int) -> m (Map ExpenseId Expense)
=
selectExpensesByMonth (y, m) $ do
MixDB.run <- select $ do
es <- from $ Table @ExpenseData
e DB.^. ExpenseDataDate) (val startDate, val endDate))
where_ (between (e pure e
let eIds = fmap entityKey es
<- select $ do
els <- from $ Table @ExpenseLabelRel
el DB.^. ExpenseLabelRelExpenseId) `in_` valList eIds)
where_ ((el pure el
pure $ Map.fromList (fromExpenseDataWith (toLabelIdsMap $ fmap entityVal els) <$> es)
where
= fromGregorian y m 1
startDay = UTCTime startDay 0
startDate = addUTCTime (-1) $ UTCTime (addGregorianMonthsClip 1 startDay) 0
endDate
fromExpenseDataWith :: Map ExpenseId (Set LabelId) -> Entity ExpenseData -> (ExpenseId, Expense)
=
fromExpenseDataWith labelMap e $ entityKey e
( fromSqlKey $ fromMaybe mempty (Map.lookup (fromSqlKey $ entityKey e) labelMap)
, toEpense (entityVal e)
)
toLabelIdsMap :: [ExpenseLabelRel] -> Map ExpenseId (Set LabelId)
=
toLabelIdsMap <>) . fmap (\(ExpenseLabelRel eid lid) -> (fromSqlKey eid, Set.singleton $ fromSqlKey lid)) Map.fromListWith (
IN
句には1000個を超える要素は渡せないが,まぁここはとりあえずあとで直す.
テストを書く
テストには tasty を利用している. テストの用の Env
を定義する:
type TestEnv = Record
"logger" >: LogFunc
'[ "sqlite" >: MixDB.Config
,
]
mkPlugin :: Text -> Mix.Plugin a m TestEnv
= hsequence
mkPlugin path $ #logger <@=> pure (mkLogFunc $ \_ _ _ _ -> pure ()) -- NoLogging
<: #sqlite <@=> MixDB.buildPluginWithoutPool path
<: nil
ロギングは要らないので何もしないロギングを渡しておく. ローカルの一時的なパスを指定してマイグレーションをするようにする:
import Test.Tasty
withMigrateOn :: MonadUnliftIO m => Text -> m TestTree -> m TestTree
=
withMigrateOn path spec
bracket
migrateForTest-> removeFile $ Text.unpack path)
(\_ const spec)
(where
= do
migrateForTest True (takeDirectory $ Text.unpack path)
createDirectoryIfMissing Mix.run (mkPlugin path) (MixDB.runMigrate migrateAll)
bracket
を利用して最後には SQLite のファイルごと削除するようにした. ちなみに,Persistent の SQLite の設定には :memory:
というオンメモリで動作するものもある. しかしこれは一つの Mix.run
でしか共有できないため今回は使いにくい. なので,愚直に一時的なテストファイルを作成することにした.
テスト自体はこんな感じ:
tests :: IO TestTree
= withMigrateOn dbPath $
tests "Homely.DB" $ do
testSpec "selectExpensesByMonth" $ do
describe "with label" $ do
context let label1 = #name @= "hoge" <: #description @= "hogege" <: nil
= #name @= "fuga" <: #description @= "fugaga" <: nil
label2 <- runIO $ runWithDB $ Set.fromList <$> mapM insertLabel [label1, label2]
labelIds let expect1 = #amount @= 1000
<: #date @= fromGregorian 2021 3 21
<: #description @= "test"
<: #labels @= labelIds
<: nil
= #amount @= 3000
expect2 <: #date @= fromGregorian 2021 3 22
<: #description @= "test"
<: #labels @= Set.take 1 labelIds
<: nil
<- runIO $ runWithDB $ do
actual <- insertExpense expect1
idx1 <- insertExpense expect2
idx2 <- selectExpensesByMonth (2021, 3)
es
deleteExpenseById idx1
deleteExpenseById idx2mapM_ deleteLabelById $ Set.toList labelIds
pure es
"insert Expense" $
it `shouldBe` [expect1, expect2]
Map.elems actual where
= "./tmp/test.sqlite"
dbPath runWithDB :: RIO TestEnv a -> IO a
= Mix.run (mkPlugin dbPath) runWithDB
他のテストへ干渉しないように,一度作ったデータは毎回削除するようにしている. ここはまぁなんか良い方法がないかおいおい考えます.
おしまい
果たしていつ完成するのやら.