八発白中

技術ブログ、改め雑記

高機能なCommon LispのO/Rマッパー「Integral」を作りました

「O/Rマッパー」や「ORM」と聞くだけで顔をしかめる人もいらっしゃいます。たぶん過去にひどい目にあったんでしょうね。その大きな理由の一つがパフォーマンスでしょう。


一昨年のYAPC::Asiaに参加したとき、ORMは使うなという話を4回くらい聞いたのが印象的でした。DBのデータはハッシュで返すか、DBIをそのまま使うほうが良いと。弊社でもパフォーマンス上の問題をわかりづらくしてしまうことから、ORMを使用しないプロジェクトがいくつかあります。

まあ、そりゃDBI使うほうが高速に動くとは思います。

しかし、僕が使っているのは実用的な言語であるCommon Lispです。実行効率と抽象化がとても得意な言語です。さらに優れたオブジェクトシステムであるCLOSも仕様に含まれています。

そこで、既存のO/RマッパーにCommon Lispらしさを加えてみるとどうだろう。

そう思って作ってみたのが、Common Lisp用のO/Rマッパー「Integral」です。

基本的な使い方

IntegralはCLOSのクラスをRDBMSのテーブルと見たててオブジェクトを操作します。

たとえば、ユーザを表すクラスとしてuserクラスがあるとしますね。

(defclass user ()
  ((name :initarg :name)))

Integralではこれに:metaclass:col-typeをつけます。:col-typeにはnameカラムのDBデータ型をシンボルで入れます。ここではとりあえずTEXT型にしておきましょう。

(import 'integral:<dao-table-class>)

(defclass user ()
  ((name :col-type text
         :initarg :name))
  (:metaclass <dao-table-class>))

定義はこれだけ。もちろん普通のクラスとして扱えます。make-instanceもできちゃう。

(make-instance 'user)
;=> #<USER %oid: <unbound>>

make-instanceしたオブジェクト一つ一つがレコードを表します。ただし、このままでは記録されません。

保存するためにDBにテーブルを作ります。IntegralはMySQLPostgreSQL、SQLite3で動きますが、今回は最も簡単なSQLite3を使います。

(import '(integral:connect-toplevel integral:ensure-table-exists))

(connect-toplevel :sqlite3 :database-name #P"/tmp/test.db")
;-> To load "dbd-sqlite3":
;     Load 1 ASDF system:
;       dbd-sqlite3
;   ; Loading "dbd-sqlite3"
;
;=> #<DBD.SQLITE3:<DBD-SQLITE3-CONNECTION> #x302002023C6D>

(ensure-table-exists 'user)
;-> CREATE TABLE IF NOT EXISTS "user" ("%oid" SERIAL NOT NULL PRIMARY KEY, "name" TEXT);
;=> NIL

これで準備完了。DBに記録するにはsave-daoを呼びます。

(import 'integral:save-dao)

(let ((user (make-instance 'user :name "深町英太郎")))
  (save-dao user))
;=> #<USER %oid: 1>

ちゃんとINSERTされたか不安なのでSELECTもしてみます。SELECT文はselect-daoで行います。

(import 'integral:select-dao)

(select-dao 'user)
;=> (#<USER %oid: 1>)

(describe (car *))
;-> #<USER %oid: 1>
;   Class: #<<DAO-TABLE-CLASS> USER>
;   Wrapper: #<CCL::CLASS-WRAPPER USER #x3020017FAE0D>
;   Instance slots
;   INTEGRAL.TABLE::%OID: 1
;   NAME: "深町英太郎"

さっきsave-daoしたインスタンスが返って来ました。

条件付きでSELECTしたいときはSxQLと同じくwherelimitなどが使えます。

(import '(integral:where integral:limit))

(select-dao 'user
  (where (:= name "深町英太郎"))
  (limit 1))
;=> (#<USER %oid: 1>)

クラス定義の変更をDBスキーマに適用する (マイグレーション)

Integralではクラス定義からテーブル定義ができることは紹介しました。ただ、途中でクラス定義を変更したらどうなるでしょうか。

Integralではクラス定義の変更に追随してDBスキーマを変更する機能があります。「マイグレーション」として広く知られている機能ですね。

たとえばさっきのuserクラスに自己紹介(profile)も欲しいな〜、と思ったら、まずはスロットを追加しましょう。

(defclass user ()
  ((name :col-type text
         :initarg :name)
   (profile :col-type text
            :initarg :profile))
  (:metaclass <dao-table-class>))

そして、migrate-tableを実行します。

(import 'integral:migrate-table)

(migrate-table 'user)
;-> ALTER TABLE "user" RENAME TO "user8797";
;   CREATE TABLE "user" ("%oid" SERIAL NOT NULL PRIMARY KEY, "name" TEXT, "profile" TEXT);
;   INSERT INTO "user" ("%oid", "name") SELECT "%oid", "name" FROM "user8797";
;=> NIL

マイグレーションで実行されたSQLがログとして出力されて完了しました。

レコードの更新処理

上述のマイグレーションにより、userテーブルにprofileも保存できるようになりました。試しに先ほどのユーザにプロフィールを設定してみます。

(import 'integral:find-dao)

(defvar *user* (find-dao 'user 1))

;; まだ自己紹介は無い
(slot-value *user* 'profile)
;=> NIL

(setf (slot-value *user* 'profile) "Common Lispとビールが好きです。")
;=> "Common Lispとビールが好きです。"

スロットにsetfしただけではDBに残らないので、更新を反映してあげます。反映は、新規追加と同じくsave-daoでできます。

(save-dao *user*)
;=> NIL

これで基本的な使い方は全部です! 簡単でしょ?

秘技: オートマイグレーション!!!

開発時はクラスの再定義をすることが多いですよね。そのたびにmigrate-tableを実行するのはなかなか面倒……。

そんな人のために、Integralにはオートマイグレーション機能が実装されています。

使い方は簡単。integral:*auto-migration-mode*Tに設定してあげれば、クラスが再定義されるたびにマイグレーション処理が走ります。

;; 再定義
(defclass user ()
  ((name :col-type text
         :initarg :name)
   (profile :col-type text
            :initarg :profile)
   (birthday :col-type date
             :initarg :birthday))
  (:metaclass <dao-table-class>))
;-> CREATE TABLE IF NOT EXISTS "user" ("%oid" SERIAL NOT NULL PRIMARY KEY, "name" TEXT, "profile" TEXT, "birthday" DATE);
;   ALTER TABLE "user" RENAME TO "user8800";
;   CREATE TABLE "user" ("%oid" SERIAL NOT NULL PRIMARY KEY, "name" TEXT, "profile" TEXT, "birthday" DATE);
;   INSERT INTO "user" ("%oid", "name", "profile") SELECT "%oid", "name", "profile" FROM "user8800";
;=> #<<DAO-TABLE-CLASS> USER>

この機能は僕のお気に入りです。

※ナチュラルにALTER TABLE文が走ってしまうので、本番環境での使用はお控えください。

もう一つの方法――DBスキーマからクラス定義する

CLOSのクラス定義からCREATE TABLE文を発行して開発を行う例を紹介しました。

けれど、「スキーマ定義はCommon Lispではなく直接DB側でやりたいなー」、という方もいらっしゃるでしょう。

Integralではずばりその機能を提供します。

(defclass user ()
  ()
  (:metaclass <dao-table-class>)
  (:generate-slots t))

先ほどに比べてスロット定義がなくなり、代わりに:generate-slots tがついています。

あとは普通に使うだけです。

(find-dao 'user 1)
;=> #<USER %oid: 1>

:generate-slots を使ったときの副次的な作用として、アクセサが勝手に定義されます。

(user-name (find-dao 'user 1))
;=> "深町英太郎"

(user-profile (find-dao 'user 1))
;=> "Common Lispとビールが好きです。"

ね、簡単でしょ? まるでActiveRecordみたい。

INSERT、UPDATE、DELETE時のフック

save-daoinsert-daoupdate-daodelete-dao はすべてメソッドになっているので、普通に:before:after:aroundメソッドを定義してやればいくらでもいじれます。CLOS万歳。

inflate と deflate

多くの言語のO/Rマッパーと同様、Integralにも「inflate」と「deflate」という機能が提供されています。これらは、DBからCommon Lispに渡ってくるときのデータの変換、逆にCommon LispのデータからDBへ保存するときのデータの変換を意味しています。

よく使われるのは日付用のカラムの変換ですね。たとえばさっきのuserクラスではbirthdayには文字列が入っています。

(user-birthday (find-dao 'user 1))
;=> "1999-03-10"

文字列じゃなくてlocal-timeのオブジェクトとして返って来て欲しい、っていう場合には以下のようにinflateメソッドを定義します。

(ql:quickload :local-time)
(import '(integral:inflate local-time:parse-timestring))

(defmethod inflate ((object user) (slot-name (eql 'birthday)) value)
  (and value
       (parse-timestring value)))

もう一度ユーザを取ってくると、ちゃんとlocal-timeのオブジェクトになります。

(slot-value (find-dao 'user 1) 'birthday)
;=> @1999-03-10T09:00:00.000000+09:00

local-timeのオブジェクトをそのままDBに記録することはできないので、逆変換のdeflateも定義しないといけません。

(import '(integral:deflate local-time:format-timestring))

(defmethod deflate ((object user) (slot-name (eql 'birthday)) value)
  (and value
       (format-timestring nil value)))

これで、DBに記録するときは予め文字列に変換するようになります。

生のSQLが書きたい?

効率や信条のために「SxQLじゃなくて文字列でSQLを書きたいんだよ」という方もいらっしゃるでしょう。そんなときはretrieve-by-sqlを使います。

(retrieve-by-sql "SELECT * FROM user")
;=> ((:|%oid| 1 :|name| "深町英太郎"
;     :|profile| "Common Lispとビールが好きです。" :|birthday| "1999-03-10")
;    (:|%oid| 2 :|name| "深町英太郎"
;     :|profile| NIL :|birthday| NIL))

:as <クラス名> をつけると結果がplistではなく、そのクラスのインスタンスで返ってきます。

(retrieve-by-sql "SELECT * FROM user" :as 'user)
;=> (#<USER %oid: 1> #<USER %oid: 2>)

おわりに

長くなるのでとりあえず使い方だけ紹介。MOPによる具体的な実装の話は以下のスーパークールなイベントで話す予定ですので興味があって関東圏に在住の方は是非ご参加ください。見るとあと4枠しかないようです。

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

IntegralはGitHubで公開しています。気に入ってくれたらStarしてくれるとうれしいですよ。