[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
```

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_one :profile
  default_scope -> { where(deleted: false) }
end
```

```ruby: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`を使って検索する。

```ruby
# 見やすいように適当に改行しています（以下同様）
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'"
```

もちろん問題ない。しかし、条件を追加するとおかしなことになる。

```ruby
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`にしてみる。

```ruby
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`するというのも試してみた。

```ruby
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`にすると壊れてしまう。

```ruby
# 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`してみると無事に回避できた。

```ruby
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`句の中で適用されているため問題ないはず。