八発白中

技術ブログ、改め雑記

軽量なCommon LispのDBライブラリ「datafly」を作りました

Common Lispのデータベースライブラリというか、O/Rマッパーとしては3ヶ月前に僕が作ったIntegralがあります。

IntegralはCLOSやMOPなどのCommon Lispの魔術を余すこと無く使い、拡張性や高度なマイグレーション機能もあるライブラリとして他の追随を許しません。

ただ、すべてのアプリケーションでO/Rマッパーのような機能が必要なわけではないでしょう。抽象化レイヤーを薄く保って、極力コントローラブルにしたいという要望もあります。

今回紹介する「datafly」はそういった要求を満たす軽量なDBライブラリです。

dataflyの思想

一般的なO/Rマッパーでは、データベースの「テーブル」と、プログラム言語の「クラス定義」が一対一対応しています。この大きな前提のおかげでデータベースを抽象化でき、まるでクラス定義が(半)永続化しているように錯覚させてくれます。

ただし、そこにはトレードオフがあります。

O/Rマッパーはその性質上データベースやSQL発行を表向き見えなくするものなので、コストのかかるSQL発行が行われているときに気づきづらくなります。

その点、dataflyは逆の思想に基づいています。

dataflyでは暗黙のSQLの発行を行いません。マクロを除く黒魔術は使いません。透明性を重視し、アプリケーションごとの最適化を行いやすくコントローラブルな状態に保ちます。

機能

上述の通り、dataflyはO/Rマッパーではありません。たとえば、dataflyは以下のようなことはしません。

dataflyがやるのはこんなことです。

  • DBコネクション管理
  • CL-DBIをラップして扱いやすく
  • 結果を構造体(Structure)にマッピング
  • inflate

CLOSの標準クラスではなく構造体を使うのでいくらか効率も良いはずです。

クイックスタート

構造体(Structure)へのマッピング

dataflyではSQLの発行方法としてretrieve-oneretrieve-allexecuteの3つの関数があります。すべて引数としてSxQLのクエリオブジェクトを受け取ります。

たとえば、SELECT文を投げて、結果を1つ返して欲しいときはretrieve-oneを使います。

(retrieve-one
  (select :*
    (from :user)
    (where (:= :name "nitro_idiot"))))
;=> (:ID 1 :NAME "nitro_idiot" :EMAIL "nitro_idiot@example.com" :REGISTERED-AT "2014-04-14T19:20:13")

返り値はプロパティリストです。

キーワード引数の:asを指定すると、結果を指定した構造体(Structure)として返します。

(defstruct user
  id
  name
  email
  registered-at)

(retrieve-one
  (select :*
    (from :user)
    (where (:= :name "nitro_idiot")))
  :as 'user)
;=> #S(USER :ID 1 :NAME "nitro_idiot" :EMAIL "nitro_idiot@example.com" :REGISTERED-AT "2014-04-14T19:20:13")

(user-name *)
;=> "nitro_idiot"

この例ではテーブル名と構造体の名前が同じですが、同じである必要は全くありません。dataflyはテーブルとクラスが一対一対応ではないからです。

この自由さは、架空のテーブル――たとえばJOINした結果――などを構造体として扱いたいときなんかに便利です。

;; "user_bank"という名前のテーブルは存在しない。
(defstruct user-bank
  user-id
  name
  bank-balance)

(retrieve-one
  (select (:user_id
           :name
           (:as (:sum (:amount))
                :bank_balance))
    (from :user)
    (left-join :bank_transactions :on (:= :user.id :bank_transactions.user_id))
    (where (:= :name "nitro_idiot")))
  :as 'user-bank)
;=> #S(USER-BANK :USER-ID 1 :NAME "nitro_idiot" :BANK-BALANCE 200000)

いずれの例でも、結果として返ってきた構造体オブジェクトにsetfで変更を加えてもO/Rマッパーのようにデータベースに更新処理が行えるわけではありません。あくまでデータベースから構造体への一方向のマッピングだけを行います。

モデル定義としての構造体

少しずつ複雑な例を紹介していきます。

上の例では単なるCommon Lispの構造体を使いました。

これだけで十分な方も多いかもしれませんが、dataflyでは少し変わった構造体を定義する機能もあります。

使い方は簡単です。defstructの代わりにdefmodelというマクロを使います。

(defmodel user
  id
  name
  email
  registered-at)

アノテーションライブラリのcl-annotを使うと@modelと書くこともできます。

(annot:enable-annot-syntax)

@model
(defstruct user
  id
  name
  email
  registered-at)

以下では@modelを使うものとします。

inflate

@modelをつけると構造体定義にいくつかの特殊なオプションをつけることができます。

その一つが:inflateです。

@model
(defstruct (user (:inflate registered-at #'datetime-to-timestamp))
  id
  name
  email
  registered-at)

(:inflate <カラム> <関数>)を記述すると、オブジェクトを作るときに指定した<カラム>の値に<関数>を自動適用します。この例ではregistered-atというカラムをLOCAL-TIMEのTIMESTAMPオブジェクトに変換します。

(defvar *user*
  (retrieve-one
    (select :*
      (from :user)
      (where (:= :name "nitro_idiot")))
    :as 'user))

;; Returns a local-time:timestamp.
(user-registered-at *user*)
;=> @2014-04-15T04:20:13.000000+09:00

:inflateは複数つけることもできます。また、<カラム>の部分をリストにして複数のカラムを指定することもできます。

オブジェクトからデータベースにINSERT/UPDATE/DELETE文を発行する機能は無いので、反対の:deflateはありません。

:has-a と :has-many

他に指定できるオプションとして:has-a:has-manyがあります。これらはテーブルのカラムの関係性を定義することで、構造体にアクセサを追加する機能です。

@model
(defstruct (user (:inflate registered-at #'datetime-to-timestamp)
                 (:has-a config (select :* (from :config) (where (:= :user_id id))))
                 (:has-many (tweets tweet)
                  (select :*
                    (from :tweet)
                    (where (:= :user_id id))
                    (order-by (:desc :created_at)))))
  id
  name
  email
  registered-at)

(defstruct config
  id
  user-id
  timezone
  country
  language)

(defstruct tweet
  id
  user-id
  body
  created-at)

この例ではuser-configuser-tweetsというアクセサが自動で定義されます。

(defvar *user*
  (retrieve-one
    (select :*
      (from :user)
      (where (:= :name "nitro_idiot")))
    :as 'user))

(user-config *user*)
;=> #S(CONFIG :ID 4 :USER-ID 1 :TIMEZONE "JST" :COUNTRY "jp" :LANGUAGE "ja")

(user-tweets *user*)
;=> (#S(TWEET :ID 2 :USER-ID 1 :BODY "Is it working?" :CREATED-AT @2014-04-16T11:02:31.000000+09:00)
;    #S(TWEET :ID 1 :USER-ID 1 :BODY "Hi." :CREATED-AT @2014-04-15T18:58:20.000000+09:00))

:has-a:has-manyで定義されたアクセサを呼び出すとSELECT文が発行されることに注意してください。

結果は初回でキャッシュされるので、二度以上呼び出しても何回もクエリが発行されるわけではないので安心してください。キャッシュを消すにはclear-object-cachesが使えます。

おわりに

Integralと違ってブログポスト一つでほとんどの機能が紹介できてしまった。JSON吐くだけのWeb APIサーバとかならこの程度で十分ですね。

今回作ったdataflyはGitHubで公開しています。

また、来週の火曜の夜は渋谷でLisp Meetupがあります。興味がある方はどうぞご参加ください。

Lisp Meet Up presented by Shibuya.lisp #16 : ATND
日時: 4/22(火) 19:30 〜 21:30
場所: 渋谷マークシティ ウエスト13階 セミナールーム

参考