kaminariにPRを出してみた
概要
OSSでRuby力を高めていきたいと思っている中,初のOSS挑戦にいいIssueがないかなと探していたところ下記Issueを発見。ただ,Issueが出されたのも2021/4で特に対応もされてなさそうだし,kaminariのgem自体も,もうそんなに活発に開発されているわけではないから意味あるかなと思いつつも,コードリーディングの機会も兼ねて出してみることに。
Issue
調査内容
total_count
というメソッドは,対象のActiveRecordモデルのレコード数を返すもの。しかし,load
メソッドと一緒に呼び出すと,正しいレコード数が返って来ないことがあるというのが今回のIssueの内容。
まずは検証。
irb(main):015:0> User.count (4.8ms) SELECT COUNT(*) FROM "users" => 5 irb(main):016:0> User.page(1).padding(5) User Load (0.4ms) SELECT "users".* FROM "users" /* loading for inspect */ LIMIT ? OFFSET ? [["LIMIT", 11], ["OFFSET", 5]] => #<ActiveRecord::Relation []> irb(main):017:0> User.page(1).padding(5).total_count (0.3ms) SELECT COUNT(*) FROM "users" => 5 irb(main):018:0> User.page(1).padding(5).load User Load (0.2ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 25], ["OFFSET", 5]] => #<ActiveRecord::Relation []> irb(main):019:0> User.page(1).padding(5).load.total_count User Load (0.3ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 25], ["OFFSET", 5]] => 0
Issue通り, User.page(1).padding(5).total_count
では本来のユーザー数である5を返すが,load
を追加したUser.page(1).padding(5).load.total_count
では0を返してしまっている。
まずは,load
の実装から見ていく。
def load(&block) unless loaded? @records = exec_queries(&block) @loaded = true end self end
loaded
はデフォルトでfalseであるため,unless内が実行される。このload
メソッドでしていることは3つ。@records
,@loaded
インスタンス変数への代入,そして,selfつまり,load
メソッドを呼び出したインスタンス自身を返す。
最初に@records
について考えていく。exec_queries(&block)
の返り値が代入されるので,exec_queries
メソッドが少しボリューミーかつ,今回ブロック引数は渡さないため,pryで空の配列が返されることだけを確認した。
[4] pry(#<User::ActiveRecord_Relation>)> exec_queries => []
@loaded
は実装通り,falseの値が代入される。
次に,total_count
の実装を見ていく。
def total_count(column_name = :all, _options = nil) #:nodoc: return @total_count if defined?(@total_count) && @total_count # There are some cases that total count can be deduced from loaded records if loaded? # Total count has to be 0 if loaded records are 0 return @total_count = 0 if (current_page == 1) && @records.empty? # Total count is calculable at the last page return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value) end # #count overrides the #select which could include generated columns referenced in #order, so skip #order here, where it's irrelevant to the result anyway c = except(:offset, :limit, :order) # Remove includes only if they are irrelevant c = c.except(:includes) unless references_eager_loaded_tables? c = c.limit(max_pages * limit_value) if max_pages && max_pages.respond_to?(:*) # .group returns an OrderedHash that responds to #count c = c.count(column_name) @total_count = if c.is_a?(Hash) || c.is_a?(ActiveSupport::OrderedHash) c.count elsif c.respond_to? :count c.count(column_name) else c end end
まずは,一行目。
return @total_count if defined?(@total_count) && @total_count
@total_count
というインスタンス変数にキャッシュしていて,キャッシュが存在すればその値を返し,なけれが後続の処理が走る。
次の処理を見ていく。
if loaded? # Total count has to be 0 if loaded records are 0 return @total_count = 0 if (current_page == 1) && @records.empty? # Total count is calculable at the last page return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value) end
先ほどのload
メソッドを実行していると,loaded?
がtrueとなり,if文の中が実行されるため,if文の処理を追っていく。
まずは,一つ目の処理を見る。
return @total_count = 0 if (current_page == 1) && @records.empty?
&&の二つ目の@records.empty?
に関しては,先ほどのload
メソッドで@records
には空の配列が代入されることを確認したので,trueとなる。なので,current_page
について調べる。current_page
の実装は以下。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
1行目から読んでいく。
offset_without_padding = offset_value
offset_value
という変数が出てきた。定義ジャンプしてもジャンプできないし,定義しているところも見つからなかったが,どうやらActiveRecordのoffset
メソッドを使用したときの引数の値が代入される模様。だが,User.page(1).padding(5).load.total_count
の時を考えているから,offset
メソッドは特に呼び出していない。そこで,page
メソッドを見にいく。実装は以下。
def self.#{Kaminari.config.page_method_name}(num = nil) per_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do include Kaminari::ActiveRecordRelationMethods include Kaminari::PageScopeMethods end end
Kaminari.config.page_method_name
==page
となる。limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do
確かにoffset
メソッドが呼ばれていた。引数は,per_page * ((num = num.to_i - 1) < 0 ? 0 : num)
。num
は引数で,今回は1。また,per_page
はper_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page
であり,max_page
は定義されていないため,default_page
のデフォルトが使用され,デフォルトが25となり,per_page
は25。よって,offset
の引数は0となり,offset_value
も0となる。User.page(1).padding(5).load.total_count
で検証してみる。
[1] pry(#<User::ActiveRecord_Relation>)> offset_value => 5
5となり,page
メソッドで呼ばれたoffset(0)
とは異なる結果となっている。実は,padding
メソッドでもoffset
メソッドが呼ばれており(というより,現在のページ数からの開始位置を決定するのがこのメソッドの本来の役割),以下のようになっている。
def padding(num) num = num.to_i raise ArgumentError, "padding must not be negative" if num < 0 @_padding = num offset(offset_value + @_padding) end
offset(offset_value + @_padding)
で,@_padding
は今回のpadding
メソッドの5となるため,offset(0 + 5)
が呼ばれるため,offset_value
も5となる。
それでは,current_page
メソッドの続きに戻る。current_page
メソッドを再掲する。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
改めて処理を追っていく。
offset_without_padding = offset_value
offset_value
は5なので,offset_without_padding
には5が代入される。
offset_without_padding -= @_padding if defined?(@_padding) && @_padding
先ほどのpadding(5)
メソッドにより,@_padding
には5が代入されているので,offset_without_padding
は0。
offset_without_padding = 0 if offset_without_padding < 0
offset_without_padding
は0なので,無視。
(offset_without_padding / limit_value) + 1
offset_without_padding
は0なので,current_page
は1となる。limit_value
は,ActiveRecordのlimit
メソッドの引数となるみたいだが,offset_value
メソッドと同様にpage
メソッドでlimit
メソッドが呼ばれている。以下,実装。
limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do
per_page
は先ほど見たようにdefault_per_page
が適用されているため,limit(25)
が呼ばれている。よって,limit_value
は25。ここまでで,current_page
の実装。
本題のtotal_count
メソッドに戻る。先ほどまで見ていた箇所は以下。
return @total_count = 0 if (current_page == 1) && @records.empty?
@records.empty?
はtrueとなり,current_page
は1であるため,ifはtrueとなり,@total_count
が0となり,早期リターンされてしまう。ここが今回のバグの原因な模様。
次に,今回のIssueと同じ状況で考える。User.page(1).per(2).padding(4).load.total_count
を考える。実際にメソッドを呼び出してみる。
[11] pry(#<User::ActiveRecord_Relation>)> User.count => 5 [12] pry(#<User::ActiveRecord_Relation>)> User.page(1).per(2).padding(4).total_count => 5 [13] pry(#<User::ActiveRecord_Relation>)> User.page(1).per(2).padding(4).load.total_count => 1
確かにIssueと同じ挙動となる。先ほどと同じようにtotal_count
メソッドを読んでいく。
return @total_count = 0 if (current_page == 1) && @records.empty?
pryでcurrent_page
の値を確認する。
[6] pry(#<User::ActiveRecord_Relation>)> current_page => 1
current_page
は前回と同じ1である。1ページあたりデータ数が2つで,5個目のデータから始め,1ページ目を表示するため,当たり前の結果ではあるが。
次に,@records
の中身をpryで確認する。
[7] pry(#<User::ActiveRecord_Relation>)> User.count => 5 [8] pry(#<User::ActiveRecord_Relation>)> @records => [#<User:0x00007f8ff8072f80 id: 5, name: "user_name5", avatar_url: "avatar5", created_at: Sat, 14 May 2022 13:02:25.931165000 UTC +00:00, updated_at: Sat, 14 May 2022 13:02:25.931165000 UTC +00:00>]
@records
がnilではなくなっている。そのため,(current_page == 1) && @records.empty?
はfalseとなり,1つ目の例の早期リターンの処理は行われない。@records
への代入には,load
メソッド内で,exec_queries
の返り値が使われている。先ほど,空の配列を常時返すというのは間違い。exec_queries
の実装を読んでいきたいところだが,少し重そうなため,今回はパス。ただ,先ほどの例と今回の例を見た感じload
メソッドを呼び出すインスタンスが返ってきそう。
一つ目の早期リターンが実行されないので,二つ目の処理を確認する。実装は以下。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
@records.any?
に関しては,先ほど@records
が存在することが確認できたので,trueが返る。また,current_page
において,limit_value
やoffset_value
が使われるため,それらを再定義しているper
メソッドの実装を追っていく。
def per(num, max_per_page: nil) max_per_page ||= ((defined?(@_max_per_page) && @_max_per_page) || self.max_per_page) @_per = (num || default_per_page).to_i if (n = num.to_i) < 0 || !(/^\d/ =~ num.to_s) self elsif n.zero? limit(n) elsif max_per_page && (max_per_page < n) limit(max_per_page).offset(offset_value / limit_value * max_per_page) else limit(n).offset(offset_value / limit_value * n) end end
まずは一行目から。
max_per_page ||= ((defined?(@_max_per_page) && @_max_per_page) || self.max_per_page)
@max_per_page
は定義されていないため,nilとなる。
@_per = (num || default_per_page).to_i
@_per
には,引数で渡しているnum=2
が入る。
次に,条件分岐を確認していく。
まずは,一つ目の/^\d/ =~ num.to_s
。^\d
は任意の数字で始まる文字列を意味する正規表現なので,今回の場合はtrueとなり,この条件式は実行されない。
二つ目の,n.zero?
はn
が2であるためfalseとなり,max_per_page
も定義されていないため,else式が実行される。実行される実装は以下。
limit(n).offset(offset_value / limit_value * n)
n == 2,offset_value == 0,limit_value == 25
となるので,実際に実行されるのは,limit(2).offset(0)
となり,limit_value == 2,offset_value == 0
となる。
次に,padding(4)
メソッドが実行されるので,その実行内容を前回同様に簡単に確認する。
def padding(num) num = num.to_i raise ArgumentError, "padding must not be negative" if num < 0 @_padding = num offset(offset_value + @_padding) end
@_padding=4
であるため,offset(0 + 4)
が実行され,offset_value=4
となる。では,最後に本題である,total_count
メソッドに戻る。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
@records.length == 1, limit_value == 2
となり,この早期リターンが実行される。まずは,current_page
の値を実装から確認する。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
offset_value == 4, @padding == 4
であり,offset_without_padding == 0
となるため,current_page == 1
となり,二つ目の例の冒頭で述べた結果と一致する。そこで,total_count
メソッドの実装に戻る。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
current_page == 1
であるため,(current_page - 1) * limit_value
の項は0となるため,total_count
の返り値は@records.length
つまり1となり,冒頭及びIssueの内容と一致する。
対応
ここまで今回のバグがなぜ起こっているのかを確認してきたわけだが,具体的な対応方法について検討する。
最初はloaded?
の条件分岐を消せば,毎回SQLを叩きにいって解決とか安易に考えていたが,それだとSQLを叩かずに計算できる時がもったいない。ならば,今回の問題としては,padding
メソッドが実行された時に,total_count
の挙動が上記で調査したようにおかしくなってしまうので,padding
メソッドが使用された時には,loaded?
の条件分岐で行われていた計算を行わないようにすれば良い。
padding
メソッドが使用された時,@_padding
というインスタンス変数が定義され,このメソッドのみで@_padding
への代入が行われていたので,以下のように条件を増やすという対応が適切だと思った。
if loaded? && !@_padding