LoginSignup
1
1

More than 5 years have passed since last update.

Kaminari の spec を走らせた話

Last updated at Posted at 2015-12-22

Kaminari という gem がありまして、お世話になっています。

    <%= paginate @collection, param_name: 'query[page]' %>

したくなったわけです。できないのですよ。なんか上手く動かない。

めっちゃ動きそうじゃん。

    <% { query: { page: 1 } }.deep_stringify_keys.deep_merge(Rack::Utils.parse_nested_query("query[page]=2")) %>

動いたんですよ。

masterのコードが使いたい

どうやらこのコード、masterブランチにあるみたいなんです。リリースされてる 0.16.3 のブランチにはなかった。なので、master が安全そうならそっちを使いたい。(stableブランチにもなかった)

安定しているかどうかは悩ましい部分ですが、多分安定してるだろうとは思ったのですね。ただまあ、一応手元の環境でテストくらいはやっておきたい。

作った人にTwitterで聞いてみるのも手かと思ったんですが、人見知りなのでアレなのと、まあその場合もテストくらいやってからでいい。あと「そんなん自分で判断しろよ」って思われそうで嫌だ。(自分だったら思う)

なのでテストを走らせたい。

テストしたい

曰く、bundle exec rake spec:all で全部テストできるぜ、とのこと。

やってみたけど当然 bundle install を求められて、しかもgemfilesディレクトリになんかいっぱいいる。これ全部 bundle install する方法ってあるんだろうか。多分あるんだろうけど、知らなかったので力技で解決する。

find gemfiles -name '*gemfile' | xargs -I{} bundle install --gemfile {}

できた。なんか最新の(Rails 5 系のコードとか?)が ruby 2.2.2+ じゃないと動かないらしいので、実際には

rvm install 2.2.2
find gemfiles -name '*gemfile' | xargs -I{} bundle install --gemfile {}

だった。rbenv は使ってない。

そこまでやる必要なかった

gemfile の違いなんですが、要するに古い環境とか ActiveRecord 以外の ORM を使っている場合のテストを走らせたいって感じなので、なにかコミットしたいならともかく、自前のプロジェクトに組み込むかどうかの大味な判断材料にするために全部の gemfile でテストする必要はないわけですね。愚かだった。

とりあえずテストは全部動作したので使ってみます。

ちなみに

Kaminari のコード読んだ記録も書いときます。ざっくり読んだだけですが。

Kaminari::ActionViewExtensionActionView の拡張です。paginate メソッドが定義されてます。

    # A helper that renders the pagination links.
    #
    #   <%= paginate @articles %>
    #
    # ==== Options
    # * <tt>:window</tt> - The "inner window" size (4 by default).
    # * <tt>:outer_window</tt> - The "outer window" size (0 by default).
    # * <tt>:left</tt> - The "left outer window" size (0 by default).
    # * <tt>:right</tt> - The "right outer window" size (0 by default).
    # * <tt>:params</tt> - url_for parameters for the links (:controller, :action, etc.)
    # * <tt>:param_name</tt> - parameter name for page number in the links (:page by default)
    # * <tt>:remote</tt> - Ajax? (false by default)
    # * <tt>:ANY_OTHER_VALUES</tt> - Any other hash key & values would be directly passed into each tag as :locals value.
    def paginate(scope, options = {}, &block)
      options[:total_pages] ||= options[:num_pages] || scope.total_pages

      paginator = Kaminari::Helpers::Paginator.new(self, options.reverse_merge(:current_page => scope.current_page, :per_page => scope.limit_value, :remote => false))
      paginator.to_s
    end

読めば大体わかります。Kaminari::Helpers::Paginator.new の 第一引数にある self ってのは、ActionView そのものですね。読むまで知らなかったんですが、ActionView のオブジェクトは params を持っていて、paginate 内部でこれを継承しています。

例えば /anywhere?params=value の時に Kaminari で生成されたページネイションのリンクのURLは /anywhere?params=value&page=2 とかになるわけです。このURLの生成時に使っています。

この辺りの処理の主役は Kaminari::Helpers::Tag クラスです。

    # A tag stands for an HTML tag inside the paginator.
    # Basically, a tag has its own partial template file, so every tag can be
    # rendered into String using its partial template.
    #
    # The template file should be placed in your app/views/kaminari/ directory
    # with underscored class name (besides the "Tag" class. Tag is an abstract
    # class, so _tag partial is not needed).
    #   e.g.)  PrevLink  ->  app/views/kaminari/_prev_link.html.erb
    #
    # When no matching template were found in your app, the engine's pre
    # installed template will be used.
    #   e.g.)  Paginator  ->  $GEM_HOME/kaminari-x.x.x/app/views/kaminari/_paginator.html.erb
    class Tag
      def initialize(template, options = {}) #:nodoc:
        @template, @options = template, options.dup
        @param_name = @options.delete(:param_name) || Kaminari.config.param_name
        @theme = @options.delete(:theme)
        @views_prefix = @options.delete(:views_prefix)
        @params = template.params.except(*PARAM_KEY_BLACKLIST).merge(@options.delete(:params) || {})
        # @params in Rails 5 does no more inherits from Hash but composes a Hash
        if @params.instance_variable_defined?(:@parameters) && !@params.respond_to?(:deep_merge)
          @params = @params.instance_variable_get :@parameters
        else
          @params = @params.with_indifferent_access
        end
      end

まあこんなコピペ読むなら元コード読んだほうが良いです。

で、まあ先ほどの paginate メソッドとこのクラスの間にはもう1クッションあったんですが、とりあえず割愛します。template がだいたい ActionView になります。したがって @params には GET パラメータが入るわけです。

生成されるページネイションの URL はいろいろあって page_url_for メソッドが担当しています。この辺はまあテストを読んだらそう書いてました。呼び出しの手順とかは読んでないのでわからないですが、ともかく URL の生成処理はここです。

      def params_for(page)
        page_params = Rack::Utils.parse_nested_query("#{@param_name}=#{page}")
        page_params = @params.deep_merge(page_params)

ここでアレしてるわけですね。deep_merge で、page_params の内容で @params を上書きしてる感じです。deep なのは再帰的にやるってことです。多分。

これで <%= paginate @collection, param_name: 'query[page]' %> とかも動くわけですね。

0.16.3の場合

まず Kaminari::Helpers::Tag@params から。

    @params = @options[:params] ? template.params.merge(@options.delete :params) : template.params

あー、ブラックリストがないんですね。

次に page_url_for

      def page_url_for(page)
        @template.url_for @params.merge(@param_name => (page <= 1 ? nil : page), :only_path => true)
      end

merge なんですね。@param_name にはオプションの params_name: ?? が入ってるので、query[page] が入ってることになります。一方 @params{ query: { page: 1 } } みたいな感じなので、 merge してもうまくいかないっぽい感じがしますね。

細かく言えばバージョンとか必要なんでしょうけど、とりあえずやってみた結果。

[1] pry(main)> { query: { page: 1 } }.merge('query[page]' => 2)
=> {:query=>{:page=>1}, "query[page]"=>2}

そうですね。

ここから先読み進めたわけではないのですが、多分もともと param_name: query[page] みたいな使い方は想定していなかったんだと思います。

kaminari/spec/helpers/helpers_spec.rb
  describe '#param_name' do
    before do
      @paginator = Paginator.new(template, :param_name => :pagina)
    end
    subject { @paginator.page_tag(template).instance_variable_get('@param_name') }
    it { should == :pagina }
  end

0.16.3 のテストで param_name のテストしてるのはここくらいな気がする。(全部精読したわけではないです)

master だと増えていて、たとえば

kaminari/spec/helpers/tags_spec.rb
      context "with param_name = 'user[page]' option" do
        before do
          helper.params.merge!(:user => {:page => "3", :scope => "active"})
        end

        context "for first page" do
          subject { Tag.new(helper, :param_name => "user[page]").page_url_for(1) }
          if ActiveSupport::VERSION::STRING < "3.1.0"
            it { should_not match(/user\[page\]=\d+/) }
            it { should match(/user\[scope\]=active/) }
          else
            it { should_not match(/user%5Bpage%5D=\d+/) } # not match user[page]=\d+
            it { should match(/user%5Bscope%5D=active/) } #     match user[scope]=active
          end
        end

        context "for other page" do
          subject { Tag.new(helper, :param_name => "user[page]").page_url_for(2) }
          if ActiveSupport::VERSION::STRING < "3.1.0"
            it { should match(/user\[page\]=2/) }
            it { should match(/user\[scope\]=active/) }
          else
            it { should match(/user%5Bpage%5D=2/) }       # match user[page]=2
            it { should match(/user%5Bscope%5D=active/) } # match user[scope]=active
          end
        end
      end

みたいなテストがされています。やったぜ!

余談

  • gemfiles の中身を全部突っ込む冴えたやり方ってなんかあるんですかね。
  • 現行の master がリリースされることを願っています。手伝う? いや英語わかんないから……。
  • gem 'kaminari', ref: '45ee71b74c7a21333e2b05ae8672a0e8c0625e38', github: 'amatsuda/kaminari' しました。
  • master 使うのがアレだったら fork してパッチ当てたやつを使うのが正しい作法なのかな? 手元の lib とかにパッチ書いて include させるのも良いのかもしれない。あんま好きなやり方ではない。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1