ページネーションでハマったと思ったら、findの問題だった
開発をしていて、ページネーションでハマったと思って、原因を探してみたら、findの仕様だったということがあったのでメモ
環境
- rails1.2.3
データ構造
- リレーション
class User < ActiveRecord::Base has_many :diaries end class Diary < ActiveRecord::Base; belongs_to :user end
事象
やりたかったこと
- 一度でも日記を書いたことのあるユーザーを日記をincludeして取得する
作戦
- INNER JOINを利用して、diariesを持っていないユーザーを取得しないでおく
@pages, @users = paginate( :user, :joins => "INNER JOIN diaries dummy ON dummy.user_id = users.id", :include => :diaries, :per_page => 5 ) # @pages.item_count #=> 15 # @users.size #=> 3
意図したとおりにレコードが取得できるはず!と思っていたのですが、なぜか、@users.sizeの値がper_pageに満たない。
paginateメソッドを追ってみる
rails1.2.3なので、ActionControllerにPaginationが標準実装されている
結局、paginateメソッドは以下のことをやっていだだけだった
# File actionpack/lib/action_controller/pagination.rb, line 182 182: def find_collection_for_pagination(model, options, paginator) 183: model.find(:all, :conditions => option[:conditions], 184: :order => options[:order_by] || options[:order], 185: :joins => options[:join] || options[:joins], :include => options[:include], 186: :select => options[:select], :limit => options[:per_page], 187: :offset => paginator.current.offset) 188: end
findを呼び出している以外に特別なことは何もしていない。
つまり、今回の事象は、findの仕様によるものだったのだ。
findメソッドを追ってみる
以下、重要なメソッドだけを記載する
# File activerecord/lib/active_record/base.rb, line 411 411: def find(*args) 412: options = extract_options_from_args!(args) 413: validate_find_options(options) 414: set_readonly_option!(options) 415: 416: case args.first 417: when :first then find_initial(options) 418: when :all then find_every(options) 419: else find_from_ids(args, options) 420: end 421: end
今回、paginateメソッドでは、find(:all)が実行されているので、find_everyメソッドをみる
# File activerecord/lib/active_record/base.rb, line 994 994: def find_every(options) 995: records = scoped?(:find, :include) || options[:include] ? 996: find_with_associations(options) : 997: find_by_sql(construct_finder_sql(options)) 998: 999: records.each { |record| record.readonly! } if options[:readonly] 1000: 1001: records 1002: end
scopeはかかっていないが、includeは指定しているので、find_with_associationsメソッドをみる
# File activerecord/lib/active_record/associations.rb, line 1020 1020: def find_with_associations(options = {}) 1021: catch :invalid_query do 1022: join_dependency = JoinDependency.new(self, merge_includes(scope(:find, :include), options[:include]), options[:joins]) 1023: rows = select_all_rows(options, join_dependency) 1024: return join_dependency.instantiate(rows) 1025: end 1026: [] 1027: end
JoinDependencyのインスタンスであるjoin_dependencyでJOIN句に入れるべきものを制御している
そして、select_all_rowsメソッドの中で、construct_finder_sql_with_included_associationsが呼び出されている
このメソッドで、DBへ投げるSQLを生成している
以前紹介したjoins オプションとinclude オプションの決定的な違い - mic_footprintsのincludeの処理もこの当たりで対応している
- column_aliases(join_dependency)でエイリアスを貼っている
# File activerecord/lib/active_record/associations.rb, line 1172 1172: def construct_finder_sql_with_included_associations(options, join_dependency) 1173: scope = scope(:find) 1174: sql = "SELECT #{column_aliases(join_dependency)} FROM #{(scope && scope[:from]) || options[:from] || table_name} " 1175: sql << join_dependency.join_associations.collect{|join| join.association_join }.join 1176: 1177: add_joins!(sql, options, scope) 1178: add_conditions!(sql, options[:conditions], scope) 1179: add_limited_ids_condition!(sql, options, join_dependency) if !using_limitable_reflections?(join_dependency.reflections) && ((scope && scope[:limit]) || options[:limit]) 1180: 1181: sql << "GROUP BY #{options[:group]} " if options[:group] 1182: 1183: add_order!(sql, options[:order], scope) 1184: add_limit!(sql, options, scope) if using_limitable_reflections?(join_dependency.reflections) 1185: add_lock!(sql, options, scope) 1186: 1187: return sanitize_sql(sql) 1188: end
paginateメソッドが生成したlimitオプションがあるので、add_limited_ids_condition!が実行され、一度、該当するidが取得される
その取得されたidを基に、アソシエーションするべくクエリが生成される
結論から言うと、この取得するidに問題があった
今回のハマりポイントに到着!!
# File activerecord/lib/active_record/associations.rb, line 1172 1190: def add_limited_ids_condition!(sql, options, join_dependency) 1191: unless (id_list = select_limited_ids_list(options, join_dependency)).empty? 1192: sql << "#{condition_word(sql)} #{table_name}.#{primary_key} IN (#{id_list}) " 1193: else 1194: throw :invalid_query 1195: end 1196: end
結局、問題は上記メソッド内で実行されるselect_limited_ids_listメソッドにあった!
- 正確に言うと、もっと奥のメソッドだけど
add_limited_ids_condition!自体は、select_limited_ids_listメソッドの返り値(idの配列)を元にSQL文を生成しているに過ぎない
select_limited_ids_listメソッド内で以下が実行される
# File activerecord/lib/active_record/associations.rb, line 1205 1205: def construct_finder_sql_for_association_limiting(options, join_dependency) 1206: scope = scope(:find) 1207: is_distinct = include_eager_conditions?(options) || include_eager_order?(options) 1208: sql = "SELECT " 1209: if is_distinct 1210: sql << connection.distinct("#{table_name}.#{primary_key}", options[:order]) 1211: else 1212: sql << primary_key 1213: end 1214: sql << " FROM #{table_name} " 1215: 1216: if is_distinct 1217: sql << join_dependency.join_associations.collect(&:association_join).join 1218: add_joins!(sql, options, scope) 1219: end 1220: 1221: add_conditions!(sql, options[:conditions], scope) 1222: if options[:order] 1223: if is_distinct 1224: connection.add_order_by_for_association_limiting!(sql, options) 1225: else 1226: sql << "ORDER BY #{options[:order]}" 1227: end 1228: end 1229: add_limit!(sql, options, scope) 1230: return sanitize_sql(sql) 1231: end
注目は、1216行目のif文
これがあることで、今回の問題が生じた
1217, 1218で、JOIN句にjoinやincludeで指定したものを入れてくれている
が、is_distinct #=> falseなので、if内は回避される
include_eager_conditions?(options) # WHEREに指定されるtableがJOIN句への挿入を必要としているか? include_eager_order?(options) # ORDER BYに指定されるtableがJOIN句への挿入を必要としているか?
今回の場合、どちらもfalseである
というわけで、JOIN句には何も挿入されないまま、idが取得される
そのあとで、idを元に、INNER JIONが指定されるので、結果としてlimitよりも少ない数が取得されてしまうことがある
今回の場合だと、以下の方法で実現が可能
@pages, @users = paginate( :user, :include => :diaries, :conditions => "diaries.id IS NOT NULL", :per_page => 5 )
この問題って、2.系以降だと変更されていそうな気もします。まだ追っていませんが。
というか、そもそも問題なのか。。。ご意見あれば是非!
最後までお読みいただきありがとうございます。