DataMapper での Associations に Hook をからませる
DataMapper での One-To-Many-Through - daily dayflower の続き。
下記はあくまで説明のためのサンプル((まじめにアプリとしてインプリメントするなら,単に User や Mail モデルに削除フラグ(というか available
フィールド?)を用意してそこを操作するだけにすると思う。))。
単純な One-To-Many (belongs_to と has n) の場合
require 'rubygems' require 'dm-core' require 'dm-aggregates' # Collection の count() 等をあとで使うため ### モデルの定義 class User include DataMapper::Resource property :id, Serial property :name, String has n, :sent_mails, :model => 'Mail', :child_key => [ 'sender_id' ] end class Mail include DataMapper::Resource property :id, Serial property :message, Text, :lazy => false belongs_to :sender, :model => 'User' end
みたいなモデルの定義だったとして,
### 初期化 DataMapper.setup :default, 'sqlite3:sample.db' DataMapper.auto_upgrade! ### User 群の作成 dayflower = User.new :name => 'dayflower' dayflower.save or raise 'could not save dayflower' ### Mail レコードの作成 mail = Mail.new( { :message => 'zap zap zap', :sender => dayflower, } ) mail.save or raise 'could not save mail' ### User 'dayflower' の送った Mail の数は? p dayflower.sent_mails.count # => 1 # 先ほど作った 1 件 ### User 'dayflower' の削除 dayflower.destroy ### 先ほど作成した Mail レコードはまだある? mail = Mail.get(1) p mail # => #<Mail @id=1 @message="zap zap zap" @sender_id=1> # Mail レコードは残存している
ふつうに設定していると,関連するレコードを削除してくれるわけではない。
なお,Document を読んでるとレコードを削除する際に
dayflower.destroy!
のように !
つきの destroy
メソッドを呼び出しているんだけど,これだと後述する Hook が呼び出されないので !
なしを呼んでいる((Document や API Doc を斜め読みした感じだと !
のありなしは validation のなしありと関連させたかったぽいけど,いまのところは逆に destroy
については validation は走らない,らしい。んで API Doc をみると !
なしの destroy
は not yet implemented ぽいんだけど,きちんと動いた。見る場所が間違ってるのかな。))。
Hook を使って関連するレコードを削除する
データベース側に外部キー制約/トリガが実装されていればそれを利用するのが王道なんだろうけど,DataMapper 側でなんとかしたい。
こんなときは User#destroy
メソッドを(alias
とか使って)オーバーライドするのかなぁ……と思ったけど,そのような「トリガ」に似た機構が DataMapper には Hook として存在している。
class User # 再 open before :destroy do sent_mails.destroy end end ### 中略 ### ### User 'dayflower' の送った Mail の数は? p dayflower.sent_mails.count # => 1 # 先ほど作った 1 件 ### User 'dayflower' の削除 dayflower.destroy ### 先ほど作成した Mail レコードはまだある? mail = Mail.get(1) p mail # => nil # Mail レコードが削除された
SQL 文からの考察
これらで吐いた SQL 文をチェックしてみる。
SELECT "id", "message", "sender_id" FROM "mails" WHERE "sender_id" = 1 ORDER BY "id" DELETE FROM "mails" WHERE "id" = 1 DELETE FROM "users" WHERE "id" = 1
User 'dayflower' を削除する「前」に,関連している Mail レコードを削除していることがわかる。
気になるのは,関連している Mail レコードを削除する部分の SQL が SELECT
してから該当する primary key のレコードを(ひとつひとつ)DELETE
しているところ。
DELETE FROM "mails" WHERE "sender_id" = 1
みたく一文で書けるし,そのほうが効率がよい。
sent_mails.destroy
の部分が悪いのかなと思って
Mail.all( :sender => self ).destroy
としてみたけど,やっぱりこのような動作になった。
でも,これ,よくよく考えたら当たり前だった。
Mail 側にも Hook をしかけていた場合,SQL 側で該当するレコードを一気に削除しちゃうと,その Hook をどれについて呼び出していいのかわからない。
だから,該当するレコードをまず列挙して(Ruby 側にもってきて),いっこいっこ削除することになる。
このへん DataMapper 側でやることのメリットデメリットのトレードオフかと思う。DataMapper 側の Hook が呼び出されるおかげで,ほかにも DB にまつわらないいろいろな処理ができるわけだし。対象となるレコードが大規模になってくるととても効率が悪くなるけど,そうなったときにチューニングするという考え方もありかも。
One-To-Many-Through の場合
考え方はまったく同じなのでコードだけ載せる。
まずはモデルの定義。
require 'rubygems' require 'dm-core' require 'dm-aggregates' ### モデルの定義 class User include DataMapper::Resource property :id, Serial property :name, String has n, :sent_mails, :model => 'Mail', :child_key => [ 'sender_id' ] has n, :received_mail_map, :model => 'Mail::Recipient' has n, :received_mails, :model => 'Mail', :through => :received_mail_map, :via => :mail before :destroy do sent_mails.destroy received_mail_map.destroy end end class Mail include DataMapper::Resource property :id, Serial property :message, Text, :lazy => false belongs_to :sender, :model => 'User' has n, :recipient_map, :model => 'Mail::Recipient' has n, :recipients, :model => 'User', :through => :recipient_map, :via => :user before :destroy do recipient_map.destroy end end class Mail::Recipient include DataMapper::Resource property :id, Serial belongs_to :mail belongs_to :user end
User の before :destroy
Hook で
sent_mails.destroy received_mail_map.destroy
のようになっていて,
received_mails.destroy
がないのは一瞬あれっと思うかも。このスキーマでは一つのメールを複数の User に送ることができるようになっている。なので,User を削除する際に関連する received_mails を削除しちゃうと,同じメールを受け取った別の人の受信箱からもなくなっちゃう。なのでこれが正しい*1。
じっさいに動かすほうのコード。
### 初期化 DataMapper.setup :default, 'sqlite3:sample.db' DataMapper.auto_upgrade! ### User 群の作成 user = {} %w( dayflower foo bar ).each do |name| user[name] = User.new :name => name user[name].save or raise "could not save #{name}" end ### User 'dayflower' の送信 Mail Mail.new( { :message => 'zip zip zip', :sender => user['dayflower'], :recipients => %w( foo bar ).map { |n| user[n] } } ).save or raise 'could not save mail' ### User 'dayflower' の受信 Mail Mail.new( { :message => 'zap zap zap', :sender => user['foo'], :recipients => %w( dayflower bar ).map { |n| user[n] } } ).save or raise 'could not save mail' ### User 'dayflower' の送った Mail の数は? p user['dayflower'].sent_mails.count # => 1 ### User 'dayflower' の受け取った Mail の数は? p user['dayflower'].received_mails.count # => 1 ### User 'dayflower' の送った Mail は残っている? mail = Mail.first :message => 'zip zip zip' p mail # => nil # Mail レコードが削除された ### User 'dayflower' の受け取った Mail は残っている? mail = Mail.first :message => 'zap zap zap' p mail # => #<Mail @id=2 @message="zap zap zap" @sender_id=2> # Mail レコードは残っている(他の受信者もいるし)
dm-constraints
dm-constraints という Plugin を使うと,Associations を張るときに外部キー制約も適宜設定してくれる。たとえば,
require 'dm-constraints' class User has n, :sent_mails, :model => 'Mail', :child_key => [ 'sender_id' ], :constraint => :destroy end
のようにすると,User レコードが削除された際に関連する Mail レコードも自動削除してくれる(DB 側の機能によって)。
ただし PostgreSQL と MySQL でしか動かないらしい。