TL;DR
- F# では ORM よりも生の SQL を書くことが多い。
FSharp.Data.SqlClientの型プロバイダを使うと、SQL の誤りがコンパイルエラーになる。 - DB のレコードをドメインオブジェクトに変換する
toDomainは、DB を信頼するかどうかで書き方が変わる。
内容
リレーショナルデータベースを扱う の一節である。
F# で SQL を実行する
F# では ORM を使うことは少なく、生の SQL を書くことが多い。方法はいくつかあるが、ここでは FSharp.Data.SqlClient の型プロバイダを使う。この型プロバイダは、コンパイル時に SQL のクエリやコマンドに合わせた型を作る。そのため、SQL のクエリが正しくなければコンパイルエラーになる。クエリが正しければ、出力と完全に一致するレコード型が返る。
例: 1 件の Customer を読み取る
CustomerId を使って 1 件の Customer を取得する readOneCustomer 関数を実装する。型は テーブルはレコード型に直接マッピングする のものを使う。
type CustomerId = CustomerId of int
type String50 = String50 of string
type Birthdate = Birthdate of DateTime
type Customer =
{ CustomerId: CustomerId
Name: String50
Birthdate: Birthdate }1. クエリの型を定義する
open FSharp.Data
[<Literal>]
let connectionString = @"..."
type ReadOneCustomer = SqlCommandProvider<"""
SELECT CustomerId, Name, Birthdate
FROM Customer
WHERE CustomerId = @customerId
""", connectionString>2. toDomain を実装する
toDomain は、DB から取得したレコードをドメインオブジェクトに変換する関数である。DB を信頼できるデータソースと見なすかどうかで、書き方が変わる。
DB を信頼できないデータソースとして扱う場合は、result コンピュテーション式を使い、戻り値の型を Result<Customer,_> にする。
let toDomain (dbRecord: ReadOneCustomer.Record): Result<Customer, _> =
result {
let! customerId =
dbRecord.CustomerId
|> CustomerId.create
let! name =
dbRecord.Name
|> String50.create
let! birthdate =
dbRecord.Birthdate
|> Result.bindOption Birthdate.create
let customer =
{ CustomerId = customerId
Name = name
Birthdate = birthdate }
return customer
}Birthdate は NULL になりうる。そこで、次の bindOption を作り、スイッチ関数を Option に対応させる(Result を生成する関数の連鎖(bind と errorMap、map))。
let bindOption f xOpt =
match xOpt with
| Some x -> f x |> Result.map Some
| None -> Ok NoneDB を信頼できるデータソースとして扱う場合は、万が一不正なデータが来たときにパニックを起こす。
let toDomain (dbRecord: ReadOneCustomer.Record): Customer =
let customerId =
dbRecord.CustomerId
|> CustomerId.create
|> panicOnError "CustomerId"
let name =
dbRecord.Name
|> String50.create
|> panicOnError "Name"
let birthdate =
dbRecord.Birthdate
|> Birthdate.create
|> panicOnError "Birthdate"
{ CustomerId = customerId; Name = name; Birthdate = birthdate }panicOnError は、Result のエラーを例外に変換するヘルパ関数である。
let panicOnError columnName result =
match result with
| Ok x -> x
| Error err ->
let msg = sprintf "%s: %A" columnName err
raise (DatabaseError msg)3. readOneCustomer を実装する
type DbReadError =
| InvalidRecord of string
| MissingRecord of string
let readOneCustomer (productionConnection: SqlConnection) (CustomerId customerId) =
use cmd = new ReadOneCustomer(productionConnection)
let records = cmd.Execute(customerId = customerId) |> Seq.toList
match records with
| [] -> // 何も見つからなかった場合
let msg = sprintf "Not found. CustomerId=%A" customerId
Error (MissingRecord msg)
| [dbCustomer] -> // 1 件だけ見つかった場合
dbCustomer |> toDomain |> Result.mapError InvalidRecord
| _ -> // 2 件以上見つかった場合
let msg = sprintf "Multiple records found for CustomerId=%A" customerId
raise (DatabaseError msg)主キーで検索しているので、複数のレコードが見つかることは起こり得ない。そのため、その場合はパニックを起こしている。
4. 汎用的な関数にする
readOneCustomer の中身をすべてパラメータにして、汎用的な関数を作る。テーブル名、ID、レコード、toDomain をパラメータとして受け取る。
let convertSingleDbRecord tableName idValue records toDomain =
match records with
| [] ->
let msg = sprintf "Not found. Table=%s Id=%A" tableName idValue
Error msg
| [dbRecord] ->
dbRecord |> toDomain |> Ok
| _ ->
let msg = sprintf "Multiple records found. Table=%s Id=%A" tableName idValue
raise (DatabaseError msg)これを使うと、readOneCustomer は短く書ける。
let readOneCustomer (productionConnection: SqlConnection) (CustomerId customerId) =
use cmd = new ReadOneCustomer(productionConnection)
let tableName = "Customer"
let records = cmd.Execute(customerId = customerId) |> Seq.toList
convertSingleDbRecord tableName customerId records toDomain