RailsのActiveRecordで、JOIN先のテーブルに条件を適用して絞り込もうとしたところ、発行されるSQLが壊れるという現象が発生してハマった。
検証したところ、どうやら次のような場合に発生するらしい。
- 親テーブルと子テーブルに同名のカラムがあり、それぞれ
default_scope
に使われている - 子テーブル(モデル)を複数回
merge
する
なお、Rails 5.1.6で起こり、Rails 5.2.0では起こらないことを確認した。
ちなみにDBはMySQLを使っている。
具体例
$ rails new . --database=mysql
$ rails generate model User deleted:boolean status_code:integer
$ rails generate model Profile deleted:boolean status_code:integer data1:text data2:text user:references
$ rails db:create
$ rails db:migrate
class User < ApplicationRecord
has_one :profile
default_scope -> { where(deleted: false) }
end
class Profile < ApplicationRecord
belongs_to :user
default_scope -> { where(deleted: false) }
end
各テーブルにdeleted
という論理削除フラグを持たせてdefault_scope
を設定している、という想定である。
UserにProfileをmergeする
ある条件を満たすProfileを持ったUserを、merge
を使って検索する。
# 見やすいように適当に改行しています(以下同様)
User.joins(:profile)
.merge(Profile.where(data1: 'hoge'))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 0
AND `profiles`.`deleted` = 0
AND `profiles`.`data1` = 'hoge'"
もちろん問題ない。しかし、条件を追加するとおかしなことになる。
User.joins(:profile)
.merge(Profile.where(data1: 'hoge'))
.merge(Profile.where(data2: 'fuga'))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 'hoge'
AND `profiles`.`data1` = 0
AND `profiles`.`deleted` = 'fuga'
AND `profiles`.`data2` = "
SQLが壊れている…?
バインドされる値がずれてしまっているような印象を受ける。当然、実行するとSQL構文エラーになる。
ここで、片方の条件をor
にしてみる。
User.joins(:profile)
.merge(Profile.where(data1: 'hoge'))
.merge(Profile.where(data2: 'fuga').or(Profile.where(data2: 'piyo')))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 0
AND `profiles`.`deleted` = 0
AND `profiles`.`data1` = 'hoge'
AND (`profiles`.`deleted` = 0 AND `profiles`.`data2` = 'fuga' OR
`profiles`.`deleted` = 0 AND `profiles`.`data2` = 'piyo')"
お、直っている。
ここからさらに条件を追加する場合も、or
をmerge
する分には問題なく、単体をmerge
するとSQLが壊れるようだ。
UserにProfile付きUserをmergeする
別のmerge
の形として、少し回りくどいがProfileをjoins
したUserをUserにmerge
するというのも試してみた。
User.merge(User.joins(:profile).where(profiles: { data1: 'hoge' }))
.merge(User.joins(:profile).where(profiles: { data2: 'fuga' }))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `profiles`.`data1` = 'hoge'
AND `users`.`deleted` = 0
AND `profiles`.`data2` = 'fuga'"
やった成功した…と安心してはいけない。
今度は逆に、条件をor
にすると壊れてしまう。
# orが先だとNG
User.merge(User.joins(:profile).where(profiles: { data1: 'hoge' }).or(User.joins(:profile).where(profiles: { data1: 'piyo' })))
.merge(User.joins(:profile).where(profiles: { data2: 'fuga' }))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE (`users`.`deleted` = 'hoge' AND `profiles`.`data1` = 'fuga' OR
`users`.`deleted` = 0 AND `profiles`.`data1` = 'piyo')
AND `users`.`deleted` =
AND `profiles`.`data2` = "
# orが後だとOK
User.merge(User.joins(:profile).where(profiles: { data1: 'hoge' }))
.merge(User.joins(:profile).where(profiles: { data2: 'fuga' }).or(User.joins(:profile).where(profiles: { data2: 'piyo' })))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 0
AND `profiles`.`data1` = 'hoge'
AND (`users`.`deleted` = 0 AND `profiles`.`data2` = 'fuga' OR
`users`.`deleted` = 0 AND `profiles`.`data2` = 'piyo')"
しかも、merge
順によって成否が分かれてしまうという微妙な結果になった。
default_scope
の影響について
default_scope
を外すと現象が起きなくなるのには気付いていたので、各モデルのdefault_scope
についていくつかの組み合わせを検証してみた。
User \ Profile | なし | deleted: false |
status_code: 1 |
---|---|---|---|
なし | OK | OK | OK |
deleted: false |
OK | NG | OK |
status_code: 1 |
OK | OK | NG |
やはり、同名のカラムに対するdefault_scope
が競合している雰囲気だ。
ちなみに、Userがstatus_code: 1
でProfileがstatus_code: 0
のような値違いの組み合わせでも駄目だった。
解決方法
default_scope
が悪さをしているらしいことは分かったので、とりあえずunscoped
してみると無事に回避できた。
User.joins(:profile)
.merge(Profile.unscoped.where(data1: 'hoge'))
.merge(Profile.unscoped.where(data2: 'fuga'))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 0
AND `profiles`.`data1` = 'hoge'
AND `profiles`.`data2` = 'fuga'"
User.joins(:profile)
.merge(Profile.unscoped.where(data1: 'hoge'))
.merge(Profile.unscoped.where(data2: 'fuga').or(Profile.unscoped.where(data2: 'piyo')))
.to_sql
=> "SELECT `users`.* FROM `users` INNER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` AND `profiles`.`deleted` = 0
WHERE `users`.`deleted` = 0
AND `profiles`.`data1` = 'hoge'
AND (`profiles`.`data2` = 'fuga' OR `profiles`.`data2` = 'piyo')"
merge
対象(=Profile)のdefault_scope
の条件については、joins
する際にINNER JOIN
句の中で適用されているため問題ないはず。