仕事で作っているアプリ用に書いたO/Rマッパのライブラリ、隠してても何の嬉しいこともないので、社内に置いてたgitのリポジトリをgithubに移した。さすがにもう機能追加の必要もなくなってきたなーという段階になったので。
https://github.com/tagomoris/Stratum
ライセンスは Apache License v2.0 としました。なにかしたい方はお好きにどうぞ。READMEとか書き中。
何のためのもの?
世の中にORMなんていくらでもあるのになんで書きはじめたんだ、ということですが、要するに以下の理由です。
- 誰が、いつ、どのようにデータを追加・更新・削除したのかをすべて残す
- そのような履歴データに簡易にアクセスする
最近監査とかなんだとかうるさいですからね。
で、こういう条件をきっちり満たしたアプリケーションを普通のORMを使って書くというのは存外に面倒。全データを残しておくことになるとデータのunique検査にDBの制約を使うわけにはいかなくなるし、普通のORMの制約も不都合がいろいろと大きくなる。
それに各更新操作で以下のような処理をいちいち書くハメになる。
- 検索キーをもとにSELECTする
- 結果から最新のものを選んで、あとは捨てる
- 選んだものをコピーして新しいインスタンスを作成する
- 対象のフィールドの値を更新して、同時にオペレータ情報と更新日時を埋める
- 更新後のインスタンスを保存する
いやいやこれを全部書いて回るとかありえないでしょ……。ないない。has_many 的にオブジェクト参照までカラムに入ってきたらマジで死ぬって。
ということで、このあたりを全部やってくれるものを作りました。おかげでアプリケーション側のコードはそんなに変なことにはなってないんじゃないかなーと思います。ただちょっとクセが強いシロモノになっちゃったけど。誰か使いやすく改良してw
あと複数対応する理由が手元にないので、ruby-mysql専用、Ruby 1.9専用です。今だとRails3のArel対応とかしたかったけど、タイミングが微妙で検討できないまま作りはじめちゃったので、してません。
大雑把な使いかた
まずMySQL側でスキーマ定義。自動でやってくれるとか、ありません。アプリケーション全体でひとつの通し番号のid("oid"と呼んでます)を共有するのでそれを払出すためのテーブル、更新処理をするためには必ずオペレータとしての型をもったユーザを登録しないといけないのでそのテーブル*1が最低限必要です。またoids以外の各テーブルには id, oid, inserted_at, operated_by, head, removed の各カラムが必要です。(Stratumが使用します。)
まず oid 払出し用のテーブルと認証情報用のテーブルを定義。認証情報のテーブル名やカラムは特に固定ではありません。使い方によって fullname とか privilege とか好きに増やすといいと思います。要するに oid を持った何かがあればいい。
CREATE TABLE oids ( id INT PRIMARY KEY NOT NULL AUTO_INCREMENT ) ENGINE=InnoDB; CREATE TABLE auth_info ( id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, oid INT NOT NULL, valid ENUM('0','1') NOT NULL DEFAULT '1', name VARCHAR(32) NOT NULL, inserted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, operated_by INT NOT NULL, head ENUM('0','1') NOT NULL DEFAULT '1', removed ENUM('0','1') NOT NULL DEFAULT '0' ) ENGINE=InnoDB charset='utf8'; INSERT INTO oids SET id=1; INSERT INTO auth_info SET oid=0,name='root',operated_by=0;
最後にrootという名前でINSERTしてるのは、セットアップなり何なりでデータを投入するときに使うユーザ情報がないと初期データ投入すらできないから。初期データ投入後は使いません。使ってはいけません。ENUM('0','1') はBOOL値ですね。
で、これに対応するモデルデータを次のように定義します。
class AuthInfo < Stratum::Model table :auth_info field :name, :string, :validator => 'accountname_checker' field :valid, :bool, :default => true def accountname_checker(str) # some code to validate username str =~ /\Ald.*\Z/ end end
簡単ですね! table でテーブル名、field で各フィールドの定義をやります。field 引数は順にフィールド名、型、オプション(ハッシュ)。オプションは型によってあれこれで、型ごとに必須のものがあります。テーブル上のカラム名はフィールド名と違えば指定できますが、同一なら省略可能。
上記モデルのデータ(認証データっぽいもの)を扱うときは以下のようなコードで。
root_user = AuthInfo.get(0) # Stratum::Model.get は oid を受け取って、最新(現在)の状態を返す root_user = AuthInfo.query(:name => "root", :unique => true) # Stratum::Model.query でフィールドの値から検索してヒットしたリストを返す、が :unique => true 指定の場合、検索時にユニーク性の検査を行って複数ヒットしたら例外を投げる Stratum.operator_model(AuthInfo) # まずどのモデルがオペレータを表すのかを指定 Stratum.current_operator(root_user) # 初期データ投入用に root をセット user1 = AuthInfo.new() user1.name = "tagomoris" # 代入時に validation チェックが行われて、この場合だと例外 FieldValidationError user1.name = "ld-tagomoris" # ok user1.save
さて上記のコードの結果だと以下みたいな状態になりますね。oidは新規オブジェクトの作成時に自動的に採番して割り当てられます。validフィールドは :default 指定してある(:boolの場合は :default は必須)ので、その値で true に。
+----+-----+-------+--------------+---------------------+-------------+------+---------+ | id | oid | valid | name | inserted_at | operated_by | head | removed | +----+-----+-------+--------------+---------------------+-------------+------+---------+ | 1 | 0 | 1 | root | 2010-10-12 19:03:12 | 0 | 1 | 0 | | 2 | 1 | 1 | ld-tagomoris | 2010-10-12 21:07:54 | 0 | 1 | 0 | +----+-----+-------+--------------+---------------------+-------------+------+---------+
では簡単にいじるため、validフィールドを false にしてみます。
tagomoris = AuthInfo.query(:name => 'ld-tagomoris').first Stratum.current_operator(tagomoris) # 普通はWebアプリケーションのログイン処理などでセット tagomoris.saved? == true # 永続化済みのオブジェクトかどうか tagomoris.valid? == true # :bool の場合は ? つきのメソッド名も自動的に作られる、けど #valid でも同じ tagomoris.valid = false tagomoris.saved? == false tagomoris.save # もし Stratum.current_operator(obj) が行われてなければ、ここで例外が発生 tagomoris.saved? == true # もし削除したかったら tagomoris.remove するだけ
で、この結果は以下のような状態に。
+----+-----+-------+--------------+---------------------+-------------+------+---------+ | id | oid | valid | name | inserted_at | operated_by | head | removed | +----+-----+-------+--------------+---------------------+-------------+------+---------+ | 1 | 0 | 1 | root | 2010-10-12 19:03:12 | 0 | 1 | 0 | | 2 | 1 | 1 | ld-tagomoris | 2010-10-12 21:07:54 | 0 | 0 | 0 | | 3 | 1 | 0 | ld-tagomoris | 2010-10-12 21:13:21 | 1 | 1 | 0 | +----+-----+-------+--------------+---------------------+-------------+------+---------+
前の状態のレコード(id=2)は head='0' として更新され、最新の状態を表すレコード(id=3)が head='1' として挿入されています。通常の query などでは head='1' AND removed='0' のもの(「現在」「存在している」レコード)のみが対象となるため、過去の履歴分や論理削除されたデータはアプリケーションから意識せずに扱うことができます。
特定のオブジェクトのこれまでの履歴を取得するのも簡単。
tagomoris_history = AuthInfo.retrospect(AuthInfo.query(:name => 'ld-tagomoris', :oidonly => true, :unique => true)) # oid で対象を指定するため、Stratum::Model.query のオプションで oid のみを返すよう指定 # 新しい方から過去に向かって順に取得される tagomoris_history[0].valid? == false tagomoris_history[0].head == true tagomoris_history[0].id == 3 tagomoris_history[0].inserted_at.to_s == '2010-10-12 21:13:21' tagomoris_history[0].operated_by.name == 'tagomoris' # AuthInfoクラスのインスタンスが返されて、その名前は 'tagomoris' tagomoris_history[0].updatable? == true # 最新のレコードから生成されたオブジェクトのみが更新可能 tagomoris_history[1].valid? == true # 更新される前の状態 tagomoris_history[1].head == false tagomoris_history[1].id == 2 tagomoris_history[1].inserted_at.to_s == '2010-10-12 21:07:54' tagomoris_history[1].operated_by.name == 'root' # このときは root によるデータ tagomoris_history[1].updatable? == false # 最新のレコードから生成されたオブジェクトのみが更新可能 tagomoris_history[1].name = 'ld-tagomoris2' # Stratum::InvalidUpdateError 例外
また他のモデルへの参照を保持する型 (:ref および :reflist) もあるため、モデル間のリレーションを作ることができます。またそういう処理をするときにはトランザクションが必須でしょう。ということで以下簡単なコード例。(テーブル定義は省略。)
class HostData < Stratum::Model table :hosts field :name, :string, :length => 32 field :type, :string, :selector => ['real', 'xen', 'vmware', 'switch', 'storage'], :default => 'real' field :hardware, :ref, :model => 'HardwareType', :empty => :ok field :ipaddresses, :reflist, :model => 'IPAddress' end class HardwareType < Stratum::Model table :hardwaretypes field :name, :string, :length => 32 field :units, :string, :validator => 'units_validator', :normalizer => 'units_upcase', :default => '1U' def units_validator(units) units =~ /\A[.0-9]+U\Z/ end def self.units_upcase(units) units.upcase end end class IPAddress < Stratum::Model table :ipaddrs field :addr, :string, :validator => 'any_ipaddress_validator' # 実装は省略… field :hosts, :reflist, :model => 'HostData', :empty => :ok end # ホストデータを作成し、IPアドレスもセットして保存したいが、失敗したら全部ロールバック Stratum.transaction do |conn| host1 = HostData.new host1.name = '謎のスイッチ' # 日本語はutf-8で入れましょう host1.type = 'switch' # セレクタの場合はリストで指定されたもの以外を入れると例外 host1.hardware = HardwareType.query_or_create(:name => 'catalyst2950') # 既に存在していればそれを引っ張ってくる、なければ作って保存した上で渡す host1.ipaddresses = IPAddress.query_or_create(:addr => '192.168.1.1') # :reflist なんで本当は host1.ipaddresses = [obj] (もしくは +=)なんだけど、これでも大丈夫 host1.save end # end に到達した時点でデータベースに対してコミットが行われ、途中で例外が発生した場合はロールバック # 参照してみると、こんな。 hostdata = HostData.query(:name => '謎のスイッチ').first hostdata.type # 'switch' hostdata.hardware.name # 'catalyst2950' hostdata.ipaddresses.first.name # '192.168.1.1' hostdata.ipaddresses.first.hosts.map(&:name) # ref/reflistへの代入時に逆方向の参照も同時に更新されるので ['謎のスイッチ'] となる hostdata.ipaddresses.first.hosts.first == hostdata
あと、queryするときに何年何月何日時点の情報が欲しい!とやると取得できたり、そうやって取得したオブジェクトのref/reflistを辿ったときにも過去日時参照が引き継がれた状態で参照されたり、論理削除済みデータまで含めて検索したり、生きてるデータの全件に対してオンメモリで正規表現検索したり、そんな感じの処理があれこれあります。
データ型は :bool / :string / :stringlist / :ref / :reflist と、あとタグづけを実現するのに :taglist がありますが、:taglist はMySQLの全文検索エンジンにべったり依存(つまりMyISAM依存)なので汎用的には使えません。まさにタグづけ専用。
他にはやっつけっぽくコネクションプールを作っていたり、データのキャッシュを持っていたり、レンダリングエンジンのpartial上でモデル参照(つまりDBへのSELECT)が走るときのためにpreload機能があったり。自分でアプリケーション作る上で必要そうな機能はだいたい入れました。