2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-07-15

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?