Rails というか ActiveRecord で has_many through
なモデルをさわっていてハマった事象があったので書きます。ただ、ハマりを回避する方法は分かっても、なぜそのような挙動になるかはまだ分かっていません。誰か教えてもらえると助かります。
前提
- Rails 5.x
- ActiveRecord
モデル
class User < ApplicationRecord
has_many :articles
has_many :game_users
has_many :games, through: :game_users
belongs_to :pinned_article, class_name: 'Article'
end
class Article < ApplicationRecord
belongs_to :user
end
class Game < ApplicationRecord
has_many :game_users
has_many :users, through: :game_users
end
class GameUser < ApplicationRecord
belongs_to :game
belongs_to :user
end
User と Game が has_many through
の関係になっています。そして、 User と Article が has_many
になっています。また、 User.pinned_article
で Article を belongs_to
で参照しています。
親子関係をまとめて save
User を基点として親子関係をまとめて save しようと思い、以下のようなコードを書きました。
# Game は予め作っておく
Game.create
u = User.new
a = u.articles.build
u.pinned_article = a
u.games << Game.last
u.save!
これを実行すると以下のような SQL が発行されます。
BEGIN
INSERT INTO `users` (`created_at`, `updated_at`) VALUES ('2017-12-15 02:24:44', '2017-12-15 02:24:44')
INSERT INTO `articles` (`user_id`, `created_at`, `updated_at`) VALUES (35, '2017-12-15 02:24:44', '2017-12-15 02:24:44')
INSERT INTO `game_users` (`game_id`, `user_id`, `created_at`, `updated_at`) VALUES (9, 35, '2017-12-15 02:24:44', '2017-12-15 02:24:44')
UPDATE `users` SET `pinned_article_id` = 32 WHERE `users`.`id` = 35
INSERT INTO `game_users` (`game_id`, `user_id`, `created_at`, `updated_at`) VALUES (9, 35, '2017-12-15 02:24:44', '2017-12-15 02:24:44')
COMMIT
一見上手く行っているように見えますが、最後の SQL でなぜか game_users
に同じレコードが重複して INSERT されています。User.pinned_article
の ID を反映するために、 INSERT 後に改めて UPDATE が走るのですが、なぜか関係のない GameUser の中間テーブルデータが改めて INSERT されています。
実際には、中間テーブルを作った場合重複データを防ぐために DB や Rails でユニーク制約を付けると思うので、SQL が失敗して ROLLBACK してしまいます。
class GameUser < ApplicationRecord
belongs_to :game
belongs_to :user
validates :user, uniqueness: { scope: [:game_id] }
end
期待した動きをするコード
以下のコードにすると期待したとおりの動きをします。違いは User.games
に直接 append するのではなく、中間テーブルの GameUser を build
して作成する所です。
u = User.new
a = u.articles.build
u.pinned_article = a
# build をして作る
gu = u.game_users.build
gu.game = Game.last
u.save!
これで無事できました。基本は build
系を使っていくのが良いんですかね。
<<
ちなみに ActiveRecord の <<
は collection_proxy で定義されています。
def <<(*records)
proxy_association.concat(records) && self
end
alias_method :push, :<<
alias_method :append, :<<
この append
を実行すると、親の ID が確定していたら即時 INSERT が実行されます。 save
とか必要ありません。
なぜこうなるのか
ActiveRecord の動きを step 実行しながら追っていきました。が、結論、なぜそうなるのかは時間切れで解明できず…。無念。また時間を作って調査をしたいと思います。
save!
で保存をする時に save_collection_association が呼ばれるのですが、ここでなぜか最後に重複データが渡ってきてしまうという…。