DB上のNULLをどう表現するか
TranqというF#専用のDBアクセスライブラリについて話します。
はじめに
Tranqでは、F#らしくDB上のNULLをoption型で扱えます。
たとえば、次のようなテーブル定義を考えます。
create table Person (Id int primary key, Name varchar(50) not null, Age int);
上のテーブルは次のようなF#のレコードにマッピングできます。
type Person = { [<Id>]Id: int; Name: string; Age: int option; }
Ageフィールドに着目してください。NULLに対応させるためにint option型になっています。
ところで、DB上でNULLで扱うからといって、それをそのままアプリケーションに持ち込む必要があるのでしょうか?
判別共用体を使えば、DB上のNULLをもっと別な形で表現できます。
ここでは、それをTranqでどう実現するかを説明します。
判別共用体でNULLを表す
あるアプリケーションでは、年齢で子供と大人とを区別する必要があるとしましょう。そして、年齢不詳の人も扱わないといけないとしましょう。
素直に考えればこれは次のような判別共用体で表せます。
type Age = | Child of int | Adult of int | Unknown
ということは、上記のデータ構造をそのままテーブルのカラムにマッピングできればいいわけですね。
つまり、ケースUnknownをDB上のNULLにマッピングします。
Tranqでは次のように書けます。
module Age = type T = private | Child of int | Adult of int | Unknown (* int option -> T *) let compose = function | Some age -> if age > 20 then Adult age else Child age | _ -> Unknown (* int T -> int option *) let decompose = function | Child age | Adult age -> Some age | Unknown -> None (* T -> T *) let incr age = decompose age |> Option.map ((+) 1) |> compose (* 判別共用体をアクティブパターンへ変換 *) let (|Child|Adult|Unknown|) = function | Child age -> Child age | Adult age -> Adult age | Unknown -> Unknown (* IDataConv<T, int option> *) let conv = { new IDataConv<T, int option> with member this.Compose(v) = compose v member this.Decompose(t) = decompose t }
簡単に解説します。
利便性のために判別共用体は専用のモジュールに定義します。
判別共用体の生成ルールはcompose関数で表します。判別共用体にprivateをつけることで、このモジュールの外で判別共用体を生成できなくしています。つまり、たとえば、100歳の子供といった矛盾したデータが作られることを防いでいます。
判別共用体をアクティブパターンへ変換していますが、これは、このモジュールの外でパターンマッチを可能にするためです。判別共用体にprivateをつけたためモジュールの外からケースを参照できなくなるのでこの対応が必要です。判別共用体に対するパターンマッチ専用のインタフェースですね。
値convをTranqに登録することで、DBとF#での値変換に使われます。つまり、DBから読み取ったときはcompose関数が呼ばれてプリミティブな値から判別共用体へ変換され、F#からDBへ書き出すときはdecompose関数が呼ばれて判別共用体がプリミティブな値に変換されます。
定義した判別共用体を利用する
こんなにたくさんコード書くのかよー、と思いました?
でも、これ、Tranqに必要なコードはconvの数行だけです。
それ以外は、判別共用体を便利に使う上でアプリケーションに必要だったり、アプリケーションで使うコードです。
カラムに対応させた判別共用体とそのモジュールをリッチにすることでロジックがすっきりするのです。
たとえば次のように書けます。
module Logic = (* Person -> Person *) let incrAge person = { person with Age = Age.incr person.Age} (* Person -> string *) let describe person = match person.Age with | Age.Child _ -> "子供" | Age.Adult _ -> "大人" | Age.Unknown -> "年齢不詳"
レコードのフィールドに露出したoption型やNullable型を使いながら同等のことをする場合を持ち出すまでもなく、簡潔に書けることがわかります。
Ageフィールドに対応する値がDB上でNULLになりうることをまったく意識する必要がありませんね。