pagination.rb 15.1 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402
module ActionController
  # === Action Pack pagination for Active Record collections
  #
  # DEPRECATION WARNING: Pagination will be separated into its own plugin with Rails 2.0.
  #
  # The Pagination module aids in the process of paging large collections of
  # Active Record objects. It offers macro-style automatic fetching of your
  # model for multiple views, or explicit fetching for single actions. And if
  # the magic isn't flexible enough for your needs, you can create your own
  # paginators with a minimal amount of code.
  #
  # The Pagination module can handle as much or as little as you wish. In the
  # controller, have it automatically query your model for pagination; or,
  # if you prefer, create Paginator objects yourself.
  #
  # Pagination is included automatically for all controllers.
  #
  # For help rendering pagination links, see 
  # ActionView::Helpers::PaginationHelper.
  #
  # ==== Automatic pagination for every action in a controller
  #
  #   class PersonController < ApplicationController   
  #     model :person
  #
  #     paginate :people, :order => 'last_name, first_name',
  #              :per_page => 20
  #     
  #     # ...
  #   end
  #
  # Each action in this controller now has access to a <tt>@people</tt>
  # instance variable, which is an ordered collection of model objects for the
  # current page (at most 20, sorted by last name and first name), and a 
  # <tt>@person_pages</tt> Paginator instance. The current page is determined
  # by the <tt>params[:page]</tt> variable.
  #
  # ==== Pagination for a single action
  #
  #   def list
  #     @person_pages, @people =
  #       paginate :people, :order => 'last_name, first_name'
  #   end
  #
  # Like the previous example, but explicitly creates <tt>@person_pages</tt>
  # and <tt>@people</tt> for a single action, and uses the default of 10 items
  # per page.
  #
  # ==== Custom/"classic" pagination 
  #
  #   def list
  #     @person_pages = Paginator.new self, Person.count, 10, params[:page]
  #     @people = Person.find :all, :order => 'last_name, first_name', 
  #                           :limit  =>  @person_pages.items_per_page,
  #                           :offset =>  @person_pages.current.offset
  #   end
  # 
  # Explicitly creates the paginator from the previous example and uses 
  # Paginator#to_sql to retrieve <tt>@people</tt> from the model.
  #
  module Pagination
    unless const_defined?(:OPTIONS)
      # A hash holding options for controllers using macro-style pagination
      OPTIONS = Hash.new
  
      # The default options for pagination
      DEFAULT_OPTIONS = {
        :class_name => nil,
        :singular_name => nil,
        :per_page   => 10,
        :conditions => nil,
        :order_by   => nil,
        :order      => nil,
        :join       => nil,
        :joins      => nil,
        :count      => nil,
        :include    => nil,
        :select     => nil,
        :parameter  => 'page'
      }
    end
      
    def self.included(base) #:nodoc:
      super
      base.extend(ClassMethods)
    end
  
    def self.validate_options!(collection_id, options, in_action) #:nodoc:
      options.merge!(DEFAULT_OPTIONS) {|key, old, new| old}

      valid_options = DEFAULT_OPTIONS.keys
      valid_options << :actions unless in_action
    
      unknown_option_keys = options.keys - valid_options
      raise ActionController::ActionControllerError,
            "Unknown options: #{unknown_option_keys.join(', ')}" unless
              unknown_option_keys.empty?

      options[:singular_name] ||= Inflector.singularize(collection_id.to_s)
      options[:class_name]  ||= Inflector.camelize(options[:singular_name])
    end

    # Returns a paginator and a collection of Active Record model instances
    # for the paginator's current page. This is designed to be used in a
    # single action; to automatically paginate multiple actions, consider
    # ClassMethods#paginate.
    #
    # +options+ are:
    # <tt>:singular_name</tt>:: the singular name to use, if it can't be inferred by singularizing the collection name
    # <tt>:class_name</tt>:: the class name to use, if it can't be inferred by
    #                        camelizing the singular name
    # <tt>:per_page</tt>::   the maximum number of items to include in a 
    #                        single page. Defaults to 10
    # <tt>:conditions</tt>:: optional conditions passed to Model.find(:all, *params) and
    #                        Model.count
    # <tt>:order</tt>::      optional order parameter passed to Model.find(:all, *params)
    # <tt>:order_by</tt>::   (deprecated, used :order) optional order parameter passed to Model.find(:all, *params)
    # <tt>:joins</tt>::      optional joins parameter passed to Model.find(:all, *params)
    #                        and Model.count
    # <tt>:join</tt>::       (deprecated, used :joins or :include) optional join parameter passed to Model.find(:all, *params)
    #                        and Model.count
    # <tt>:include</tt>::    optional eager loading parameter passed to Model.find(:all, *params)
    #                        and Model.count
    # <tt>:select</tt>::     :select parameter passed to Model.find(:all, *params)
    #
    # <tt>:count</tt>::      parameter passed as :select option to Model.count(*params)
    #
    def paginate(collection_id, options={})
      Pagination.validate_options!(collection_id, options, true)
      paginator_and_collection_for(collection_id, options)
    end

    # These methods become class methods on any controller 
    module ClassMethods
      # Creates a +before_filter+ which automatically paginates an Active
      # Record model for all actions in a controller (or certain actions if
      # specified with the <tt>:actions</tt> option).
      #
      # +options+ are the same as PaginationHelper#paginate, with the addition 
      # of:
      # <tt>:actions</tt>:: an array of actions for which the pagination is
      #                     active. Defaults to +nil+ (i.e., every action)
      def paginate(collection_id, options={})
        Pagination.validate_options!(collection_id, options, false)
        module_eval do
          before_filter :create_paginators_and_retrieve_collections
          OPTIONS[self] ||= Hash.new
          OPTIONS[self][collection_id] = options
        end
      end
    end

    def create_paginators_and_retrieve_collections #:nodoc:
      Pagination::OPTIONS[self.class].each do |collection_id, options|
        next unless options[:actions].include? action_name if
          options[:actions]

        paginator, collection = 
          paginator_and_collection_for(collection_id, options)

        paginator_name = "@#{options[:singular_name]}_pages"
        self.instance_variable_set(paginator_name, paginator)

        collection_name = "@#{collection_id.to_s}"
        self.instance_variable_set(collection_name, collection)     
      end
    end
  
    # Returns the total number of items in the collection to be paginated for
    # the +model+ and given +conditions+. Override this method to implement a
    # custom counter.
    def count_collection_for_pagination(model, options)
      model.count(:conditions => options[:conditions],
                  :joins => options[:join] || options[:joins],
                  :include => options[:include],
                  :select => options[:count])
    end
    
    # Returns a collection of items for the given +model+ and +options[conditions]+,
    # ordered by +options[order]+, for the current page in the given +paginator+.
    # Override this method to implement a custom finder.
    def find_collection_for_pagination(model, options, paginator)
      model.find(:all, :conditions => options[:conditions],
                 :order => options[:order_by] || options[:order],
                 :joins => options[:join] || options[:joins], :include => options[:include],
                 :select => options[:select], :limit => options[:per_page],
                 :offset => paginator.current.offset)
    end
  
    protected :create_paginators_and_retrieve_collections,
              :count_collection_for_pagination,
              :find_collection_for_pagination

    def paginator_and_collection_for(collection_id, options) #:nodoc:
      klass = options[:class_name].constantize
      page  = params[options[:parameter]]
      count = count_collection_for_pagination(klass, options)
      paginator = Paginator.new(self, count, options[:per_page], page)
      collection = find_collection_for_pagination(klass, options, paginator)
    
      return paginator, collection 
    end
      
    private :paginator_and_collection_for

    # A class representing a paginator for an Active Record collection.
    class Paginator
      include Enumerable

      # Creates a new Paginator on the given +controller+ for a set of items
      # of size +item_count+ and having +items_per_page+ items per page.
      # Raises ArgumentError if items_per_page is out of bounds (i.e., less
      # than or equal to zero). The page CGI parameter for links defaults to
      # "page" and can be overridden with +page_parameter+.
      def initialize(controller, item_count, items_per_page, current_page=1)
        raise ArgumentError, 'must have at least one item per page' if
          items_per_page <= 0

        @controller = controller
        @item_count = item_count || 0
        @items_per_page = items_per_page
        @pages = {}
        
        self.current_page = current_page
      end
      attr_reader :controller, :item_count, :items_per_page
      
      # Sets the current page number of this paginator. If +page+ is a Page
      # object, its +number+ attribute is used as the value; if the page does 
      # not belong to this Paginator, an ArgumentError is raised.
      def current_page=(page)
        if page.is_a? Page
          raise ArgumentError, 'Page/Paginator mismatch' unless
            page.paginator == self
        end
        page = page.to_i
        @current_page_number = has_page_number?(page) ? page : 1
      end

      # Returns a Page object representing this paginator's current page.
      def current_page
        @current_page ||= self[@current_page_number]
      end
      alias current :current_page

      # Returns a new Page representing the first page in this paginator.
      def first_page
        @first_page ||= self[1]
      end
      alias first :first_page

      # Returns a new Page representing the last page in this paginator.
      def last_page
        @last_page ||= self[page_count] 
      end
      alias last :last_page

      # Returns the number of pages in this paginator.
      def page_count
        @page_count ||= @item_count.zero? ? 1 :
                          (q,r=@item_count.divmod(@items_per_page); r==0? q : q+1)
      end

      alias length :page_count

      # Returns true if this paginator contains the page of index +number+.
      def has_page_number?(number)
        number >= 1 and number <= page_count
      end

      # Returns a new Page representing the page with the given index
      # +number+.
      def [](number)
        @pages[number] ||= Page.new(self, number)
      end

      # Successively yields all the paginator's pages to the given block.
      def each(&block)
        page_count.times do |n|
          yield self[n+1]
        end
      end

      # A class representing a single page in a paginator.
      class Page
        include Comparable

        # Creates a new Page for the given +paginator+ with the index
        # +number+. If +number+ is not in the range of valid page numbers or
        # is not a number at all, it defaults to 1.
        def initialize(paginator, number)
          @paginator = paginator
          @number = number.to_i
          @number = 1 unless @paginator.has_page_number? @number
        end
        attr_reader :paginator, :number
        alias to_i :number

        # Compares two Page objects and returns true when they represent the 
        # same page (i.e., their paginators are the same and they have the
        # same page number).
        def ==(page)
          return false if page.nil?
          @paginator == page.paginator and 
            @number == page.number
        end

        # Compares two Page objects and returns -1 if the left-hand page comes
        # before the right-hand page, 0 if the pages are equal, and 1 if the
        # left-hand page comes after the right-hand page. Raises ArgumentError
        # if the pages do not belong to the same Paginator object.
        def <=>(page)
          raise ArgumentError unless @paginator == page.paginator
          @number <=> page.number
        end

        # Returns the item offset for the first item in this page.
        def offset
          @paginator.items_per_page * (@number - 1)
        end
        
        # Returns the number of the first item displayed.
        def first_item
          offset + 1
        end
        
        # Returns the number of the last item displayed.
        def last_item
          [@paginator.items_per_page * @number, @paginator.item_count].min
        end

        # Returns true if this page is the first page in the paginator.
        def first?
          self == @paginator.first
        end

        # Returns true if this page is the last page in the paginator.
        def last?
          self == @paginator.last
        end

        # Returns a new Page object representing the page just before this
        # page, or nil if this is the first page.
        def previous
          if first? then nil else @paginator[@number - 1] end
        end

        # Returns a new Page object representing the page just after this
        # page, or nil if this is the last page.
        def next
          if last? then nil else @paginator[@number + 1] end
        end

        # Returns a new Window object for this page with the specified 
        # +padding+.
        def window(padding=2)
          Window.new(self, padding)
        end

        # Returns the limit/offset array for this page.
        def to_sql
          [@paginator.items_per_page, offset]
        end
        
        def to_param #:nodoc:
          @number.to_s
        end
      end

      # A class for representing ranges around a given page.
      class Window
        # Creates a new Window object for the given +page+ with the specified
        # +padding+.
        def initialize(page, padding=2)
          @paginator = page.paginator
          @page = page
          self.padding = padding
        end
        attr_reader :paginator, :page

        # Sets the window's padding (the number of pages on either side of the
        # window page).
        def padding=(padding)
          @padding = padding < 0 ? 0 : padding
          # Find the beginning and end pages of the window
          @first = @paginator.has_page_number?(@page.number - @padding) ?
            @paginator[@page.number - @padding] : @paginator.first
          @last =  @paginator.has_page_number?(@page.number + @padding) ?
            @paginator[@page.number + @padding] : @paginator.last
        end
        attr_reader :padding, :first, :last

        # Returns an array of Page objects in the current window.
        def pages
          (@first.number..@last.number).to_a.collect! {|n| @paginator[n]}
        end
        alias to_a :pages
      end
    end

  end
end