jsonb-uy/rotulus

View on GitHub
lib/rotulus/page.rb

Summary

Maintainability
A
2 hrs
Test Coverage
module Rotulus
  class Page
    attr_reader :ar_relation, :order, :limit, :cursor

    delegate :columns, :state, to: :order, prefix: true

    # Creates a new Page instance representing a subset of the given ActiveRecord::Relation
    # records sorted using the given 'order' definition param.
    #
    # @param ar_relation [ActiveRecord::Relation] the base relation instance to be paginated
    # @param order [Hash<Symbol, Hash>, Hash<Symbol, Symbol>, nil] the order definition of columns.
    #   Same with SQL 'ORDER BY', columns listed first takes precedence in the sorting of records.
    #   The order param allows 2 formats: expanded and compact. Expanded format exposes some config
    #   which allows more control in generating the optimal SQL queries to filter page records.
    #
    #   Available options for each column in expanded order definition:
    #   * direction (Symbol) the sort direction, +:asc+ or +:desc+. Default: +:asc+.
    #   * nullable (Boolean) whether a null value is expected for this column in the query result.
    #     Note that for queries with table JOINs, a column could have a null value
    #     even if the column doesn't allow nulls in its table so :nullable might need to be set to
    #     +true+ for such cases.
    #     Default: +true+ if :nullable option value is nil and the
    #     column is defined as nullable in its table otherwise, false.
    #   * nulls (Symbol) null values sorting, +:first+ for +NULLS FIRST+ and
    #     +:last+ for +NULLS LAST+. Applicable only if column is :nullable.
    #   * distinct (Boolean) whether the column value is expected to be unique in the result.
    #     Note that for queries with table JOINs, multiple rows could have the same column
    #     value even if the column has a unique index defined in its table so :distinct might
    #     need to be set to +false+ for such cases.
    #     Default: true if :distinct option value is nil and the column is the PK of its
    #     table otherwise, false.
    #   * model (Class) Model where the column belongs to.
    #
    # @param limit [Integer] the number of records per page. Defaults to the +config.page_default_limit+.
    #
    # @example Using expanded order definition (Recommended)
    #  Rotulus::Page.new(User.all, order: { last_name: { direction: :asc },
    #                              first_name: { direction: :desc, nulls: :last },
    #                              ssn: { direction: :asc, distinct: true } }, limit: 3)
    #
    # @example Using compact order definition
    #  Rotulus::Page.new(User.all, order: { last_name: :asc, first_name: :desc, ssn: :asc }, limit: 3)
    #
    # @raise [InvalidLimit] if the :limit exceeds the configured :page_max_limit or if the
    #  :limit is not a positive number.
    def initialize(ar_relation, order: { id: :asc }, limit: nil)
      unless limit_valid?(limit)
        raise InvalidLimit.new("Allowed page limit is 1 up to #{config.page_max_limit}")
      end

      @ar_relation = ar_relation || model.none
      @order = Order.new(model, order)
      @limit = (limit.presence || config.page_default_limit).to_i
    end

    # Return a new page pointed to the given cursor(in encoded token format)
    #
    # @param token [String] Base64-encoded representation of cursor.
    #
    # @example
    #   page = Rotulus::Page.new(User.where(last_name: 'Doe'), order: { first_name: :desc }, limit: 2)
    #   page.at('eyI6ZiI6eyJebyI6IkFjdGl2ZVN1cHBvcnQ6Okhhc2hXaXRoSW5kaWZm...')
    #
    # @return [Page] page instance
    def at(token)
      page_copy = dup
      page_copy.at!(token)
      page_copy
    end

    # Point the same page instance to the given cursor(in encoded token format)
    #
    # @example
    #   page = Rotulus::Page.new(User.where(last_name: 'Doe'), order: { first_name: :desc }, limit: 2)
    #   page.at!('eyI6ZiI6eyJebyI6IkFjdGl2ZVN1cHBvcnQ6Okhhc2hXaXRoSW5kaWZm...')
    #
    # @param token [String] Base64-encoded representation of cursor
    # @return [self] page instance
    def at!(token)
      @cursor = token.present? ? config.cursor_class.for_page_and_token!(self, token) : nil

      reload
    end

    # Get the records for this page. Note an extra record is fetched(limit + 1)
    # to make it easier to check whether a next or previous page exists.
    #
    # @return [Array<ActiveRecord::Base>] array of records for this page.
    def records
      return loaded_records[1..limit] if paged_back? && extra_row_returned?

      loaded_records[0...limit]
    end

    # Clear memoized records to lazily force to initiate the query again.
    #
    # @return [self] page instance
    def reload
      @loaded_records = nil

      self
    end

    # Check if a next page exists
    #
    # @return [Boolean] returns true if a next page exists, otherwise returns false.
    def next?
      ((cursor.nil? || paged_forward?) && extra_row_returned?) || paged_back?
    end

    # Check if a preceding page exists
    #
    # @return [Boolean] returns true if a previous page exists, otherwise returns false.
    def prev?
      (paged_back? && extra_row_returned?) || !cursor.nil? && paged_forward?
    end

    # Check if the page is the 'root' page; meaning, there are no preceding pages.
    #
    # @return [Boolean] returns true if the page is the root page, otherwise false.
    def root?
      cursor.nil? || !prev?
    end

    # Generate the cursor token to access the next page if one exists
    #
    # @return [String] Base64-encoded representation of cursor
    def next_token
      return unless next?

      record = cursor_reference_record(:next)
      return if record.nil?

      config.cursor_class.new(record, :next).to_token
    end

    # Generate the cursor token to access the previous page if one exists
    #
    # @return [Cursor] Base64-encoded representation of cursor
    def prev_token
      return unless prev?

      record = cursor_reference_record(:prev)
      return if record.nil?

      config.cursor_class.new(record, :prev).to_token
    end

    # Next page instance
    #
    # @return [Page] the next page with records after the last record of this page.
    def next
      return unless next?

      at next_token
    end

    # Previous page instance
    #
    # @return [Page] the previous page with records preceding the first record of this page.
    def prev
      return unless prev?

      at prev_token
    end

    # Generate a hash containing the previous and next page cursor tokens
    #
    # @return [Hash] the hash containing the cursor tokens
    def links
      return {} if records.empty?

      {
        previous: prev_token,
        next: next_token
      }.delete_if { |_, token| token.nil? }
    end

    # Return Hashed value of this page's state so we can check whether the base ar_relation has
    # changed(e.g. SQL/filters from API params). see Cursor.for_page_and_token!
    #
    # @return [String] the hashed state
    def query_state
      data = ar_relation.to_sql

      Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
    end

    # Returns a string showing the page's records in table form with the ordered columns
    # as the columns. This method is primarily used to test/debug the pagination behavior.
    #
    # @return [String] table
    def as_table
      Rotulus::PageTableizer.new(self).tableize
    end

    def inspect
      cursor_info = cursor.nil? ? '' : " cursor='#{cursor}'"

      "#<#{self.class.name} ar_relation=#{ar_relation} order=#{order} limit=#{limit}#{cursor_info}>"
    end

    private

    delegate :model, to: :ar_relation, prefix: false

    # If this is the root page or when paginating forward(#paged_forward), limit+1
    # includes the first record of the next page. This lets us know whether there is a page
    # succeeding the current page. When paginating backwards(#paged_back?), the limit+1 includes the
    # record prior to the current page's first record(last record of the previous page, if it exists)
    # -letting us know that the current page has a previous/preceding page.
    def loaded_records
      return @loaded_records unless @loaded_records.nil?

      @loaded_records = ar_relation.where(cursor&.sql)
                                   .reorder(order_by_sql)
                                   .limit(limit + 1)
                                   .select(*select_columns)
      return @loaded_records.to_a unless paged_back?

      # Reverse the returned records in case #paged_back? as the sorting is also reversed.
      @loaded_records = @loaded_records.reverse
    end

    # Query in #loaded_records uses limit + 1. Returns true if an extra row was retrieved.
    def extra_row_returned?
      loaded_records.size > limit
    end

    def paged_back?
      !!cursor&.prev?
    end

    def paged_forward?
      !!cursor&.next?
    end

    def cursor_reference_record(direction)
      record = direction == :next ? records.last : records.first

      Record.new(self, order.selected_values(record))
    end

    # SELECT the ordered columns so we can use the values to generate the 'where' condition
    # to filter next/prev page's records. Alias and normalize those columns so we can access
    # the values using record#slice.
    def select_columns
      base_select_values = ar_relation.select_values.presence || [Rotulus.db.select_all_sql(model.table_name)]
      base_select_values << order.select_sql
      base_select_values
    end

    def order_by_sql
      return order.reversed_sql if paged_back?

      order.sql
    end

    def limit_valid?(limit)
      return true if limit.blank?

      limit = limit.to_i
      limit >= 1 && limit <= config.page_max_limit
    end

    def config
      @config ||= Rotulus.configuration
    end
  end
end