Rails
Rails5

[Rails] 複数回mergeするとSQLが壊れる現象について

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
app/models/user.rb
class User < ApplicationRecord
  has_one :profile
  default_scope -> { where(deleted: false) }
end
app/models/profile.rb
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')"

お、直っている。
ここからさらに条件を追加する場合も、ormergeする分には問題なく、単体を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句の中で適用されているため問題ないはず。