DataMapper での One-To-Many-Through

DataMapper 0.10.1 が対象。

単純な One-To-Many ではなくて、ひとつ間にテーブルをかます One-To-Many-Through のおはなし。

たとえばユーザ・ユーザ間のメールのやりとりをモデルに起こすとして、あるユーザが複数のユーザに同時に同じメッセージを送ることができるとすると、

  • メールを表すテーブル
  • メールの受信者を表すテーブル

をわけることになる(まあメールレコードを受信者数分コピーすれば One-To-Many でもできるけど)。

そんなときの DataMapper の使い方の話(DataMapper を使う (Associations) - KrdLab's blog がとても参考になった)。

モデルの定義

  • User はユーザを表すモデル
  • Mail はメールを表すモデル
  • Mail::Recipient はそのメールの受信者(へのマッピング)を表すモデル
require 'rubygems'
require 'dm-core'

class User
  include DataMapper::Resource

  property :id, Serial
  property :name, String
end

class Mail
  include DataMapper::Resource

  property :id, Serial
  property :message, Text

  belongs_to :sender, :model => 'User'

  has n, :recipient_maps, :model => 'Mail::Recipient'
  has n, :recipients, :model => 'User',
    :through => :recipient_maps, :via => :user
end

class Mail::Recipient
  include DataMapper::Resource

  property :id, Serial
  belongs_to :mail
  belongs_to :user
end
  • Mail::Recipient:id を定義しているのは、普通にスキーマ定義すると必要ないと思うんだけど、DataMapper 0.10.1 ではないと怒られた。
  • :class_name というオプションは obsolete になったので代わりに :model というオプションを使う
  • :via というオプションは Through テーブルで参照するプロパティの指定に用いている
    • この例(:via => :user)では :recipients というプロパティ(Collection)は、:recipient_maps というモデル(つまり Mail::Recipient)を参照しているけれど、そのモデルのなかの :user プロパティをダイレクトに指す
    • つまり、User モデルの集合体となる

これらはプロパティ名を適切に名づけると省略することが可能になるんだけど、実際にスキーマを設計していくと DataMapper にとっては適切でない名付けになることが多いと思う。

データの挿入

実際にデータを挿入するところ。

DataMapper.setup :default, 'sqlite3:sample.db'
DataMapper.auto_upgrade!


foo = User.new :name => 'foo'
foo.save or raise 'could not save foo'

bar = User.new :name => 'bar'
bar.save or raise 'could not save bar'

dayflower = User.new :name => 'dayflower'
dayflower.save or raise 'could not save dayflower'


mail = Mail.new
mail.attributes = {
  :message   => 'zap zap zap',

  :sender    => dayflower,
# :sender_id => dayflower.id,
}

mail.recipients << bar << foo

mail.save or raise 'could not save mail'
  • mail.attributes = のところは new() の引数にしてもいいんだけど、こういう書き方もできるということで
  • :sender = でモデルインスタンスdayflower)を代入しているところは、コメントアウトしたみたいに :sender_id で実フィールドに代入することもできる。前者のほうが直感的で後者は実直。
  • mail.recipients << のところが DataMapper での *-To-Many な Association の醍醐味
    • もちろん Mail::Recipient モデルに対応するテーブルに直接代入することもできる(後述)

データの参照

mail = Mail.first
puts "From: #{mail.sender.name}"

mail.recipients.each do |r|
  puts "To: #{r.name}"
end

puts "Message: #{mail.message}"

実行すると、

From: dayflower
To: foo
To: bar
Message: zap zap zap

のように出力される。

SQL の参照

DataMapper が吐いた SQL 文を確認するには、

DataMapper.logger.set_log STDERR, :debug, "", true

のようにする。FAQ 等のドキュメントでは :debug のとこまででいいみたいだけど、0.10.1 では第4引数(ログファイルを作成するかどうかフラグ)を true として指定しないと標準出力系に出力されなかった。

ユーザ登録部分は省略。

まず、メールの登録部分。

mail.save

は、以下のような SQL 文が発行される。

INSERT INTO "mails" ("message", "sender_id") VALUES ('zap zap zap', 3)
SELECT "id", "mail_id", "user_id" FROM "mail_recipients" WHERE "mail_id" = 1 AND "user_id" = 2 ORDER BY "id" LIMIT 1
INSERT INTO "mail_recipients" ("mail_id", "user_id") VALUES (1, 2)
SELECT "id", "mail_id", "user_id" FROM "mail_recipients" WHERE "mail_id" = 1 AND "user_id" = 1 ORDER BY "id" LIMIT 1
INSERT INTO "mail_recipients" ("mail_id", "user_id") VALUES (1, 1)

mail_recipients に INSERT する前にいちいち SELECT してるのは、

mail.recipients << bar << foo

のように書くとこうなるみたい。いちいち既存かどうかチェックするのね。

さっきいったみたいに、Mail::Recipient モデルに直接(でもないけど)マッピングを挿入する場合、

mail.recipient_maps.new :mail_id => mail.id, :user_id => bar.id
mail.recipient_maps.new :mail_id => mail.id, :user_id => foo.id

のように書くことになる(以前は #.build() というメソッドだったんだけど、#.new() を使うようになった)。

この場合の SQL 文は、

INSERT INTO "mails" ("message", "sender_id") VALUES ('zap zap zap', 3)
INSERT INTO "mail_recipients" ("mail_id", "user_id") VALUES (1, 2)
INSERT INTO "mail_recipients" ("mail_id", "user_id") VALUES (1, 1)

のようになる。無駄な SELECT 文がなくなった。


つぎに、レコードの参照部分。

mail = Mail.first

は、

SELECT "id", "sender_id" FROM "mails" ORDER BY "id" LIMIT 1

になる。

んで、

puts "From: #{mail.sender.name}"

のところで

SELECT "id", "name" FROM "users" WHERE "id" = 3 ORDER BY "id" LIMIT 1

が発行される。

PerlDBIC の場合だと PREFETCH というオプションをつけると sender (User) まで一気通貫に読んでくれたりするんだけど、DataMapper だとそういうのないのかな?

んで、

mail.recipients.each do |r|
  puts "To: #{r.name}"
end

は、

SELECT "users"."id", "users"."name" FROM "users" INNER JOIN "mail_recipients" ON "users"."id" = "mail_recipients"."user_id" INNER JOIN "mails" ON "mail_recipients"."mail_id" = "mails"."id" WHERE "mail_recipients"."mail_id" = 1 GROUP BY "users"."id", "users"."name" ORDER BY "users"."id"

となる。mails まで INNER JOIN してるのは無駄な気もする。

で、

puts "Message: #{mail.message}"

が、

SELECT "id", "message" FROM "mails" WHERE "id" = 1 ORDER BY "id"

となる。

そんなのさっきの Mail.first のときに一緒に読み込んどけよ、と思うけど、これは message プロパティが Text であり、これはデフォルトで lazy loading されるから。モデルの定義のとこで :lazy => false するとさっきのところで一緒に読み込んでくれる。

おまけ

「あるメールの受信者が複数いる」ということのためにテーブルをわけたんだけど、逆に「あるユーザは複数の受信メールをもっている」ということもいえる。

なので、User モデル側にも To-Many-Through を記述すると、取得できる。

class User  # 再 open
  has n, :received_mail_maps, :model => 'Mail::Recipient'
  has n, :received_mails, :model => 'Mail',
    :through => :received_mail_maps, :via => :mail
end

foo = User.first :name => 'foo'

foo.received_mails.each do |mail|
  puts "From: #{mail.sender.name}"
  puts "Message: #{mail.message}"
end

しかし DataMapper はドキュメントが(日本語英語によらず)少ないー。