【Rails3.2】【バッドノウハウ】includes 使用時に where で ドットを使うと想定外になるかも知れない件、または、なぜRails4 から references が必要になったのか
皆さん。お久しぶりです。MUGENUP の osada です。
某死にすぎるゲームの1周目をクリアしたので、戻ってまいりました!
目標を達成するために大事なことは観察力である、ということを思い知らされるゲームでした。
さてそんな今回は、Rails 3.2 の includes に関するバッドノウハウの共有です。
弊社 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_items を join するつもりがないのに、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 loading と LEFT 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)
使用したテストを置いておきます。
- ruby_test/rails_test/rails3.2/test01/spec/models/user_spec.rb at master · osdakira/ruby_test
- ruby_test/rails_test/rails4.1.0/test01/spec/models/user_spec.rb at master · osdakira/ruby_test
備考1
SQLの確認に、to_sql ではなくexplain を使っているのは、Rails 3系のto_sqlは、eager loading を考慮しないためです。
Rails 4系では修正されています。