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 = 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
が発行される。
Perl の DBIC の場合だと 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 はドキュメントが(日本語英語によらず)少ないー。