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 をみると ! なしの destroynot 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 レコードを削除する部分の SQLSELECT してから該当する 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 側の機能によって)。

ただし PostgreSQLMySQL でしか動かないらしい。

*1:ほんとは?Mail クラスに Mail::Recipient の無い宙ぶらりんな Mail レコードを検索して削除する static メソッドを用意してそれを呼び出すようにするのもいいかもしんない。でもそこまでいくと生に近い SQL を書くことになると思うんだけど,DataMapper ではどう記述すればいいんだろう。