【Rails3.2】【バッドノウハウ】includes 使用時に where で ドットを使うと想定外になるかも知れない件、または、なぜRails4 から references が必要になったのか

皆さん。お久しぶりです。MUGENUP の osada です。 某死にすぎるゲームの1周目をクリアしたので、戻ってまいりました! 目標を達成するために大事なことは観察力である、ということを思い知らされるゲームでした。

さてそんな今回は、Rails 3.2includes に関するバッドノウハウの共有です。 弊社 okuda が、トライ・アンド・エラーの末に見つけた、バグの解説になります。 Rails 4 系 をお使いの方には不要ですので、何かの話のネタになれば幸いです。

includes は LEFT OUTER JOIN と eager loading の 2 種類がある

Railsでは、includes は 2種類の使い方ができます。 1. eager loading 2. LEFT OUTER JOIN

通常の使い方だと、(1) の eager loading となり、 where 句の中で、includes した テーブルを参照すると、 (2) の LEFT OUTER JOIN となる、という認識でした。

class User < ActiveRecord::Base
  attr_accessible :email, :name
  has_many :user_items
end

describe User do
  let!(:user) { create :user, name: "user0", email: "user0@example.com" }

  it "eager loading" do
    subject = User.where("`name` LIKE ?", "%user0%").explain
    expect(subject).to include "SELECT `users`.* FROM `users`  WHERE (`name` LIKE '%user0%')"
    # => User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE (`name` LIKE '%user0%')
    # => UserItem Load (1.3ms)  SELECT `user_items`.* FROM `user_items` WHERE `user_items`.`user_id` IN (252)
  end

  it "outer join by string" do
    subject = User.includes(:user_items).where("user_items.number IS NULL").explain
    expect(subject).to include "LEFT OUTER JOIN"
    # => SQL (2.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE `user_items`.`number` IS NULL
  end
end

一体何が問題なのか?

上でLEFT OUTER JOIN になる条件を

where 句の中で、includes した テーブルを参照する と書いたのですが、それは誤りでした。

端的に言うと、 where 句の中で、ドットが含まれていれば、LEFT OUTER JOIN とみなす 、ということだったのです。

何が問題になるのでしょうか?

皆さん。where 句に email を使ったことはありませんか?

it "like email" do
  subject = User.includes(:user_items).where("`email` LIKE ?", "%user0@example.com%").explain
  expect(subject).to include "LEFT OUTER JOIN"
  expect(subject).to include "SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (`email` LIKE '%user0@example.com%')"
end

user_itemsjoin するつもりがないのに、join されています。 email にはほとんど必ずドットが 含まれます。 email 以外では eager loading なのに、email で where をした途端に、いきなりLEFT OUTER JOIN になってしまうのです。 無論それは、email に限りません。nameでドットが含まれていれば同じことです。

it "like name with dot" do
  subject = User.includes(:user_items).where("`name` LIKE ?", "%user0.name%").explain
  expect(subject).to include "LEFT OUTER JOIN"
end

さらには、stringで構築しているから、という理由でもありません。 arel_table で構築しても、結果は同じです。

it "like name with dot on arel" do
   subject = User.includes(:user_items).where(User.arel_table[:name].matches("%user0.name%")).explain
   expect(subject).to include "LEFT OUTER JOIN"
end

なぜこんなことになってしまうのでしょうか?

それは、eager loadingLEFT OUTER JOIN を区別するものが、下記のメソッドだからです。

references_eager_loaded_tables? で、どちらなのかを決めますが、 その決定権を握るのがtables_in_stringです。

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (tables_in_string(to_sql) - joined_tables).any?
end

def tables_in_string(string)
  return [] if string.blank?
  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  # ignore raw_sql_ that is used by Oracle adapter as alias for limit/offset subqueries
  string.scan(/([a-zA-Z_][.\w]+).?\./).flatten.map{ |s| s.downcase }.uniq - ['raw_sql_']
end

string.scan の中で、sql の中に、ドットが含まれているかどうか、のみで判断していることが見て取れると思います。

末尾の行を pry した結果が下記です。

[1] pry(#<ActiveRecord::Relation>)> to_sql
=> "SELECT `users`.* FROM `users`  WHERE (`users`.`name` LIKE '%user0.name%')"
[2] pry(#<ActiveRecord::Relation>)> tables_in_string(to_sql)
=> ["users", "user0"]
[3] pry(#<ActiveRecord::Relation>)> joined_tables
=> ["users"]
[4] pry(#<ActiveRecord::Relation>)> (tables_in_string(to_sql) - joined_tables).any?
=> true

確かに、ドットで判断していることが分かります。

まとめ

以上が、Rails3.2で、includesに起きる、バッドノウハウです。 知っていればどうということはありませんが、意図せず想定外の結果を産むこともありますので、十分お気をつけ下さい。

むしろ、この機会に、最近リリースされたRails4.1.0に乗り換えるのも良いのではないでしょうか。

(おまけ) Rails 4.1 では、LEFT OUTER JOIN の使用に、references が必要になった。

さて、そんなわけで、Rails 4から、LEFT OUTER JOINしたい時は明示的に、references` を書くことになりました。

User.includes(:user_items).where("user_items.number IS NULL")
# => Mysql2::Error: Unknown column 'user_items.number' in 'where clause': SELECT `users`.* FROM `users`  WHERE (user_items.number IS NULL)

User.includes(:user_items).where("user_items.number IS NULL").references(:user_items)
# => SQL (0.4ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (user_items.number IS NULL)

上述のメソッドは、下記のように書き換わりました

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (references_values - joined_tables).any?
end

変更されているのは、末尾の (references_values - joined_tables).any? です。 3.2系のときは、(tables_in_string(to_sql) - joined_tables).any? でした。

これらの問題については、こちらで検討され、修正がされたようです。 includes eager loading is realized with LEFT OUTER JOIN strategy when querying with value contains 'dot' · Issue #7177 · rails/rails

Deprecatedになった、ということでしたが、今回試したところ、エラーになりました。 確認したところ、Rails 4.1 では、使用不可になったようです。 Remove implicit join references that were deprecated in 4.0.

(おまけ 2) Arel を使おう

事例を説明するために、where の中に文字列を書きましたが、sexy ではありません。 綺麗に書きましょう。

it "like name call outer join" do
    subject = User.includes(:user_items).where(user_items: { number: nil } ).explain
    expect(subject).to include "LEFT OUTER JOIN"
end

(おまけ 3)

使用したテストを置いておきます。

備考1

SQLの確認に、to_sql ではなくexplain を使っているのは、Rails 3系のto_sqlは、eager loading を考慮しないためです。 Rails 4系では修正されています。

ActiveRecord::Relation#to_sql does not match SQL executed when using includes · Issue #6132 · rails/rails