ページネーションでハマったと思ったら、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の処理もこの当たりで対応している

     # 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.系以降だと変更されていそうな気もします。まだ追っていませんが。
というか、そもそも問題なのか。。。ご意見あれば是非!



最後までお読みいただきありがとうございます。