【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
系では修正されています。