rails_cursor_pagination 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +38 -1
- data/lib/rails_cursor_pagination/configuration.rb +3 -1
- data/lib/rails_cursor_pagination/cursor.rb +110 -0
- data/lib/rails_cursor_pagination/paginator.rb +89 -73
- data/lib/rails_cursor_pagination/timestamp_cursor.rb +84 -0
- data/lib/rails_cursor_pagination/version.rb +1 -1
- data/lib/rails_cursor_pagination.rb +12 -0
- metadata +9 -6
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 2410928079b14757601fdbb84b8c516a99070f5eee181ab464beeeeb00f302b6
         | 
| 4 | 
            +
              data.tar.gz: 7f7e5a25d3977ad18964d35d0e2715978c57ccfdfe8624d229bd275deeb1b542
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 349ba3f27691109cc3e374dbc0c8340163b8af0b452f158e806f859b7b88e4f6ba7b88bb9a5b9a3c9080ccf53d00c100ba7e166f4b0aec2e2fa0c300591b7ff5
         | 
| 7 | 
            +
              data.tar.gz: 802a9c37b80c59dc52c747d6727afa48650572448294114a1f5b70c6092a72d272beb51eeda50b13d2b009598396688eff4969e1d44f13bfa09f7a2f7097c490
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -14,6 +14,33 @@ These are the latest changes on the project's `master` branch that have not yet | |
| 14 14 | 
             
              Follow the same format as previous releases by categorizing your feature into "Added", "Changed", "Deprecated", "Removed", "Fixed", or "Security".
         | 
| 15 15 | 
             
            --->
         | 
| 16 16 |  | 
| 17 | 
            +
            ## [0.4.0] - 2023-10-06
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            ### Changed
         | 
| 20 | 
            +
            - **Breaking change:** Raised minimum required Ruby version to 2.7
         | 
| 21 | 
            +
            - **Breaking change:** Raised minimum required `activerecord` version to 6.0
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            ### Added
         | 
| 24 | 
            +
            - Test against Ruby version 3.2
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            ### Fixed
         | 
| 27 | 
            +
            - **Breaking change:** Ensure timestamp `order_by` fields (like `created_at`) will paginate results by honoring timestamp order down to microsecond resolution on comparison. This was done by changing the cursor logic for timestamp fields, which means that the cursors strings change from version 0.3.0 to 0.4.0 and old cursors cannot be decoded by the new gem version anymore. 
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            ## [0.3.0] - 2022-07-08
         | 
| 30 | 
            +
             | 
| 31 | 
            +
            ### Added
         | 
| 32 | 
            +
            - Add a `limit` param to paginator that can be used instead either `first` or `last`
         | 
| 33 | 
            +
            - Add a `max_page_size` to the configuration, allowing to set a global limit to the page size (non overridable): Default `nil`
         | 
| 34 | 
            +
            - Support explicitly requesting all columns via `.select(*)` without re-including the requested column
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            ### Removed
         | 
| 37 | 
            +
            - **Breaking change:** Drop support for Ruby 2.5 (EOL 2021-03-31)
         | 
| 38 | 
            +
             | 
| 39 | 
            +
            ### Changed
         | 
| 40 | 
            +
            - **Breaking change:** Remove nesting of `ParameterError` and `InvalidCursorError` errors, they are now directly nested under the main gem module. So they're now `RailsCursorPagination::ParameterError` and `RailsCursorPagination::InvalidCursorError`.
         | 
| 41 | 
            +
            - Refactor paginator cursor interactions into exposed `RailsCursorPagination::Cursor` class
         | 
| 42 | 
            +
            - Require multi-factor-authentication to publish the gem on Rubygems
         | 
| 43 | 
            +
             | 
| 17 44 | 
             
            ## [0.2.0] - 2021-04-19
         | 
| 18 45 |  | 
| 19 46 | 
             
            ### Changed
         | 
    
        data/README.md
    CHANGED
    
    | @@ -120,6 +120,21 @@ RailsCursorPagination::Paginator | |
| 120 120 | 
             
              .fetch
         | 
| 121 121 | 
             
            ```
         | 
| 122 122 |  | 
| 123 | 
            +
            Alternatively, you can use the `limit` column with either `after` or `before`.
         | 
| 124 | 
            +
            This will behave like either `first` or `last` respectively and fetch X records.
         | 
| 125 | 
            +
             | 
| 126 | 
            +
            ```ruby
         | 
| 127 | 
            +
            RailsCursorPagination::Paginator
         | 
| 128 | 
            +
              .new(posts, limit: 2, after: 'MTA=')
         | 
| 129 | 
            +
              .fetch
         | 
| 130 | 
            +
            ```
         | 
| 131 | 
            +
             | 
| 132 | 
            +
            ```ruby
         | 
| 133 | 
            +
            RailsCursorPagination::Paginator
         | 
| 134 | 
            +
              .new(posts, limit: 2, before: 'MTA=')
         | 
| 135 | 
            +
              .fetch
         | 
| 136 | 
            +
            ```
         | 
| 137 | 
            +
             | 
| 123 138 | 
             
            ### Ordering
         | 
| 124 139 |  | 
| 125 140 | 
             
            As said, this gem ignores any previous ordering added to the passed relation.
         | 
| @@ -216,7 +231,15 @@ end | |
| 216 231 | 
             
            ```
         | 
| 217 232 |  | 
| 218 233 | 
             
            This would set the default page size to 50.
         | 
| 219 | 
            -
             | 
| 234 | 
            +
             | 
| 235 | 
            +
            You can also select a global `max_page_size` to prevent a client from requesting too large a page.
         | 
| 236 | 
            +
             | 
| 237 | 
            +
            ```ruby
         | 
| 238 | 
            +
            RailsCursorPagination.configure do |config|
         | 
| 239 | 
            +
              config.max_page_size = 100
         | 
| 240 | 
            +
            end
         | 
| 241 | 
            +
            ```
         | 
| 242 | 
            +
             | 
| 220 243 | 
             
            ### The passed relation
         | 
| 221 244 |  | 
| 222 245 | 
             
            The relation passed to the `RailsCursorPagination::Paginator` needs to be an instance of an `ActiveRecord::Relation`.
         | 
| @@ -404,6 +427,20 @@ You can also run `bin/console` for an interactive prompt that will allow you to | |
| 404 427 | 
             
            To install this gem onto your local machine, run `bundle exec rake install`.
         | 
| 405 428 | 
             
            To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
         | 
| 406 429 |  | 
| 430 | 
            +
            ## Supported environments
         | 
| 431 | 
            +
             | 
| 432 | 
            +
            This gem should run in any project that uses:
         | 
| 433 | 
            +
            * Ruby
         | 
| 434 | 
            +
            * `ActiveRecord`
         | 
| 435 | 
            +
            * Postgres or MySQL
         | 
| 436 | 
            +
             | 
| 437 | 
            +
            We aim to support all versions that are still actively maintained and extend support until one year past the version's EOL.
         | 
| 438 | 
            +
            While we think it's important to stay up-to-date with versions and update as soon as an EOL is reached, we know that this is not always immediately possible.
         | 
| 439 | 
            +
            This way, we hope to strike a balance between being usable by most projects without forcing them to upgrade, but also keeping the supported version combinations manageable.
         | 
| 440 | 
            +
             | 
| 441 | 
            +
            This project is tested against different permutations of Ruby versions and DB versions, both Postgres and MySQL.
         | 
| 442 | 
            +
            Please check the [test automation file under `./.github/workflows/test.yml`](.github/workflows/test.yml) to see all officially supported combinations.
         | 
| 443 | 
            +
             | 
| 407 444 | 
             
            ## Contributing
         | 
| 408 445 |  | 
| 409 446 | 
             
            Bug reports and pull requests are welcome on GitHub at https://github.com/xing/rails_cursor_pagination.
         | 
| @@ -10,12 +10,13 @@ module RailsCursorPagination | |
| 10 10 | 
             
              #
         | 
| 11 11 | 
             
              #    RailsCursorPagination.configure do |config|
         | 
| 12 12 | 
             
              #      config.default_page_size = 42
         | 
| 13 | 
            +
              #      config.max_page_size = 100
         | 
| 13 14 | 
             
              #    end
         | 
| 14 15 | 
             
              #
         | 
| 15 16 | 
             
              class Configuration
         | 
| 16 17 | 
             
                include Singleton
         | 
| 17 18 |  | 
| 18 | 
            -
                attr_accessor :default_page_size
         | 
| 19 | 
            +
                attr_accessor :default_page_size, :max_page_size
         | 
| 19 20 |  | 
| 20 21 | 
             
                # Ensure the default values are set on first initialization
         | 
| 21 22 | 
             
                def initialize
         | 
| @@ -25,6 +26,7 @@ module RailsCursorPagination | |
| 25 26 | 
             
                # Reset all values to their defaults
         | 
| 26 27 | 
             
                def reset!
         | 
| 27 28 | 
             
                  @default_page_size = 10
         | 
| 29 | 
            +
                  @max_page_size = nil
         | 
| 28 30 | 
             
                end
         | 
| 29 31 | 
             
              end
         | 
| 30 32 | 
             
            end
         | 
| @@ -0,0 +1,110 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require 'base64'
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module RailsCursorPagination
         | 
| 6 | 
            +
              # Cursor class that's used to uniquely identify a record and serialize and
         | 
| 7 | 
            +
              # deserialize this cursor so that it can be used for pagination.
         | 
| 8 | 
            +
              class Cursor
         | 
| 9 | 
            +
                attr_reader :id, :order_field_value
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                class << self
         | 
| 12 | 
            +
                  # Generate a cursor for the given record and ordering field. The cursor
         | 
| 13 | 
            +
                  # encodes all the data required to then paginate based on it with the
         | 
| 14 | 
            +
                  # given ordering field.
         | 
| 15 | 
            +
                  #
         | 
| 16 | 
            +
                  # @param record [ActiveRecord]
         | 
| 17 | 
            +
                  #   Model instance for which we want the cursor
         | 
| 18 | 
            +
                  # @param order_field [Symbol]
         | 
| 19 | 
            +
                  #   Column or virtual column of the record that the relation is ordered by
         | 
| 20 | 
            +
                  # @return [Cursor]
         | 
| 21 | 
            +
                  def from_record(record:, order_field: :id)
         | 
| 22 | 
            +
                    new(id: record.id, order_field: order_field,
         | 
| 23 | 
            +
                        order_field_value: record[order_field])
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  # Decode the provided encoded cursor. Returns an instance of this
         | 
| 27 | 
            +
                  # +RailsCursorPagination::Cursor+ class containing either just the
         | 
| 28 | 
            +
                  # cursor's ID or in case of pagination on any other field, containing
         | 
| 29 | 
            +
                  # both the ID and the ordering field value.
         | 
| 30 | 
            +
                  #
         | 
| 31 | 
            +
                  # @param encoded_string [String]
         | 
| 32 | 
            +
                  #   The encoded cursor
         | 
| 33 | 
            +
                  # @param order_field [Symbol]
         | 
| 34 | 
            +
                  #   Optional. The column that is being ordered on in case it's not the ID
         | 
| 35 | 
            +
                  #   column
         | 
| 36 | 
            +
                  # @return [RailsCursorPagination::Cursor]
         | 
| 37 | 
            +
                  def decode(encoded_string:, order_field: :id)
         | 
| 38 | 
            +
                    decoded = JSON.parse(Base64.strict_decode64(encoded_string))
         | 
| 39 | 
            +
                    if order_field == :id
         | 
| 40 | 
            +
                      if decoded.is_a?(Array)
         | 
| 41 | 
            +
                        raise InvalidCursorError,
         | 
| 42 | 
            +
                              "The given cursor `#{encoded_string}` was decoded as " \
         | 
| 43 | 
            +
                              "`#{decoded}` but could not be parsed"
         | 
| 44 | 
            +
                      end
         | 
| 45 | 
            +
                      new(id: decoded, order_field: :id)
         | 
| 46 | 
            +
                    else
         | 
| 47 | 
            +
                      unless decoded.is_a?(Array) && decoded.size == 2
         | 
| 48 | 
            +
                        raise InvalidCursorError,
         | 
| 49 | 
            +
                              "The given cursor `#{encoded_string}` was decoded as " \
         | 
| 50 | 
            +
                              "`#{decoded}` but could not be parsed"
         | 
| 51 | 
            +
                      end
         | 
| 52 | 
            +
                      new(id: decoded[1], order_field: order_field,
         | 
| 53 | 
            +
                          order_field_value: decoded[0])
         | 
| 54 | 
            +
                    end
         | 
| 55 | 
            +
                  rescue ArgumentError, JSON::ParserError
         | 
| 56 | 
            +
                    raise InvalidCursorError,
         | 
| 57 | 
            +
                          "The given cursor `#{encoded_string}` could not be decoded"
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
                end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                # Initializes the record
         | 
| 62 | 
            +
                #
         | 
| 63 | 
            +
                # @param id [Integer]
         | 
| 64 | 
            +
                #   The ID of the cursor record
         | 
| 65 | 
            +
                # @param order_field [Symbol]
         | 
| 66 | 
            +
                #   The column or virtual column for ordering
         | 
| 67 | 
            +
                # @param order_field_value [Object]
         | 
| 68 | 
            +
                #   Optional. The value that the +order_field+ of the record contains in
         | 
| 69 | 
            +
                #   case that the order field is not the ID
         | 
| 70 | 
            +
                def initialize(id:, order_field: :id, order_field_value: nil)
         | 
| 71 | 
            +
                  @id = id
         | 
| 72 | 
            +
                  @order_field = order_field
         | 
| 73 | 
            +
                  @order_field_value = order_field_value
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  return if !custom_order_field? || !order_field_value.nil?
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                  raise ParameterError, 'The `order_field` was set to ' \
         | 
| 78 | 
            +
                                        "`#{@order_field.inspect}` but " \
         | 
| 79 | 
            +
                                        'no `order_field_value` was set'
         | 
| 80 | 
            +
                end
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                # Generate an encoded string for this cursor. The cursor encodes all the
         | 
| 83 | 
            +
                # data required to then paginate based on it with the given ordering field.
         | 
| 84 | 
            +
                #
         | 
| 85 | 
            +
                # If we only order by ID, the cursor doesn't need to include any other data.
         | 
| 86 | 
            +
                # But if we order by any other field, the cursor needs to include both the
         | 
| 87 | 
            +
                # value from this other field as well as the records ID to resolve the order
         | 
| 88 | 
            +
                # of duplicates in the non-ID field.
         | 
| 89 | 
            +
                #
         | 
| 90 | 
            +
                # @return [String]
         | 
| 91 | 
            +
                def encode
         | 
| 92 | 
            +
                  unencoded_cursor =
         | 
| 93 | 
            +
                    if custom_order_field?
         | 
| 94 | 
            +
                      [@order_field_value, @id]
         | 
| 95 | 
            +
                    else
         | 
| 96 | 
            +
                      @id
         | 
| 97 | 
            +
                    end
         | 
| 98 | 
            +
                  Base64.strict_encode64(unencoded_cursor.to_json)
         | 
| 99 | 
            +
                end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                private
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                # Returns true when the order has been overridden from the default (ID)
         | 
| 104 | 
            +
                #
         | 
| 105 | 
            +
                # @return [Boolean]
         | 
| 106 | 
            +
                def custom_order_field?
         | 
| 107 | 
            +
                  @order_field != :id
         | 
| 108 | 
            +
                end
         | 
| 109 | 
            +
              end
         | 
| 110 | 
            +
            end
         | 
| @@ -11,18 +11,13 @@ module RailsCursorPagination | |
| 11 11 | 
             
              #       .fetch
         | 
| 12 12 | 
             
              #
         | 
| 13 13 | 
             
              class Paginator
         | 
| 14 | 
            -
                # Generic error that gets raised when invalid parameters are passed to the
         | 
| 15 | 
            -
                # Paginator initializer
         | 
| 16 | 
            -
                class ParameterError < Error; end
         | 
| 17 | 
            -
             | 
| 18 | 
            -
                # Error that gets raised if a cursor given as `before` or `after` parameter
         | 
| 19 | 
            -
                # cannot be properly parsed
         | 
| 20 | 
            -
                class InvalidCursorError < ParameterError; end
         | 
| 21 | 
            -
             | 
| 22 14 | 
             
                # Create a new instance of the `RailsCursorPagination::Paginator`
         | 
| 23 15 | 
             
                #
         | 
| 24 16 | 
             
                # @param relation [ActiveRecord::Relation]
         | 
| 25 17 | 
             
                #   Relation that will be paginated.
         | 
| 18 | 
            +
                # @param limit [Integer, nil]
         | 
| 19 | 
            +
                #   Number of records to return in pagination. Can be combined with either
         | 
| 20 | 
            +
                #   `after` or `before` as an alternative to `first` or `last`.
         | 
| 26 21 | 
             
                # @param first [Integer, nil]
         | 
| 27 22 | 
             
                #   Number of records to return in a forward pagination. Can be combined
         | 
| 28 23 | 
             
                #   with `after`.
         | 
| @@ -43,14 +38,15 @@ module RailsCursorPagination | |
| 43 38 | 
             
                # @param order [Symbol, nil]
         | 
| 44 39 | 
             
                #   Ordering to apply, either `:asc` or `:desc`. Defaults to `:asc`.
         | 
| 45 40 | 
             
                #
         | 
| 46 | 
            -
                # @raise [RailsCursorPagination:: | 
| 41 | 
            +
                # @raise [RailsCursorPagination::ParameterError]
         | 
| 47 42 | 
             
                #   If any parameter is not valid
         | 
| 48 | 
            -
                def initialize(relation,  | 
| 49 | 
            -
                               order_by: nil, order: nil)
         | 
| 43 | 
            +
                def initialize(relation, limit: nil, first: nil, after: nil, last: nil,
         | 
| 44 | 
            +
                               before: nil, order_by: nil, order: nil)
         | 
| 50 45 | 
             
                  order_by ||= :id
         | 
| 51 46 | 
             
                  order ||= :asc
         | 
| 52 47 |  | 
| 53 | 
            -
                   | 
| 48 | 
            +
                  ensure_valid_params_values!(relation, order, limit, first, last)
         | 
| 49 | 
            +
                  ensure_valid_params_combinations!(first, last, limit, before, after)
         | 
| 54 50 |  | 
| 55 51 | 
             
                  @order_field = order_by
         | 
| 56 52 | 
             
                  @order_direction = order
         | 
| @@ -62,8 +58,14 @@ module RailsCursorPagination | |
| 62 58 | 
             
                  @page_size =
         | 
| 63 59 | 
             
                    first ||
         | 
| 64 60 | 
             
                    last ||
         | 
| 61 | 
            +
                    limit ||
         | 
| 65 62 | 
             
                    RailsCursorPagination::Configuration.instance.default_page_size
         | 
| 66 63 |  | 
| 64 | 
            +
                  if Configuration.instance.max_page_size &&
         | 
| 65 | 
            +
                     Configuration.instance.max_page_size < @page_size
         | 
| 66 | 
            +
                    @page_size = Configuration.instance.max_page_size
         | 
| 67 | 
            +
                  end
         | 
| 68 | 
            +
             | 
| 67 69 | 
             
                  @memos = {}
         | 
| 68 70 | 
             
                end
         | 
| 69 71 |  | 
| @@ -83,50 +85,79 @@ module RailsCursorPagination | |
| 83 85 |  | 
| 84 86 | 
             
                private
         | 
| 85 87 |  | 
| 86 | 
            -
                # Ensure that the parameters of this service  | 
| 87 | 
            -
                # a `RailsCursorPagination:: | 
| 88 | 
            +
                # Ensure that the parameters of this service have valid values, otherwise
         | 
| 89 | 
            +
                # raise a `RailsCursorPagination::ParameterError`.
         | 
| 88 90 | 
             
                #
         | 
| 89 91 | 
             
                # @param relation [ActiveRecord::Relation]
         | 
| 90 92 | 
             
                #   Relation that will be paginated.
         | 
| 93 | 
            +
                # @param order [Symbol]
         | 
| 94 | 
            +
                #   Must be :asc or :desc
         | 
| 95 | 
            +
                # @param limit [Integer, nil]
         | 
| 96 | 
            +
                #   Optional, must be positive
         | 
| 91 97 | 
             
                # @param first [Integer, nil]
         | 
| 92 | 
            -
                #   Optional, must be positive | 
| 93 | 
            -
                # @param after [String, nil]
         | 
| 94 | 
            -
                #   Optional, cannot be combined with `before`
         | 
| 98 | 
            +
                #   Optional, must be positive
         | 
| 95 99 | 
             
                # @param last [Integer, nil]
         | 
| 96 | 
            -
                #   Optional, must be positive | 
| 97 | 
            -
                #   with `first`
         | 
| 98 | 
            -
                # @param before [String, nil]
         | 
| 99 | 
            -
                #   Optional, cannot be combined with `after`
         | 
| 100 | 
            -
                # @param order [Symbol]
         | 
| 101 | 
            -
                #   Optional, must be :asc or :desc
         | 
| 100 | 
            +
                #   Optional, must be positive
         | 
| 101 | 
            +
                #   with `first` or `limit`
         | 
| 102 102 | 
             
                #
         | 
| 103 | 
            -
                # @raise [RailsCursorPagination:: | 
| 103 | 
            +
                # @raise [RailsCursorPagination::ParameterError]
         | 
| 104 104 | 
             
                #   If any parameter is not valid
         | 
| 105 | 
            -
                def  | 
| 105 | 
            +
                def ensure_valid_params_values!(relation, order, limit, first, last)
         | 
| 106 106 | 
             
                  unless relation.is_a?(ActiveRecord::Relation)
         | 
| 107 107 | 
             
                    raise ParameterError,
         | 
| 108 | 
            -
                          'The first argument must be an ActiveRecord::Relation, but was '\
         | 
| 108 | 
            +
                          'The first argument must be an ActiveRecord::Relation, but was ' \
         | 
| 109 109 | 
             
                          "the #{relation.class} `#{relation.inspect}`"
         | 
| 110 110 | 
             
                  end
         | 
| 111 111 | 
             
                  unless %i[asc desc].include?(order)
         | 
| 112 112 | 
             
                    raise ParameterError,
         | 
| 113 113 | 
             
                          "`order` must be either :asc or :desc, but was `#{order}`"
         | 
| 114 114 | 
             
                  end
         | 
| 115 | 
            +
                  if first.present? && first.negative?
         | 
| 116 | 
            +
                    raise ParameterError, "`first` cannot be negative, but was `#{first}`"
         | 
| 117 | 
            +
                  end
         | 
| 118 | 
            +
                  if last.present? && last.negative?
         | 
| 119 | 
            +
                    raise ParameterError, "`last` cannot be negative, but was `#{last}`"
         | 
| 120 | 
            +
                  end
         | 
| 121 | 
            +
                  if limit.present? && limit.negative?
         | 
| 122 | 
            +
                    raise ParameterError, "`limit` cannot be negative, but was `#{limit}`"
         | 
| 123 | 
            +
                  end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                  true
         | 
| 126 | 
            +
                end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                # Ensure that the parameters of this service are combined in a valid way.
         | 
| 129 | 
            +
                # Otherwise raise a +RailsCursorPagination::ParameterError+.
         | 
| 130 | 
            +
                #
         | 
| 131 | 
            +
                # @param limit [Integer, nil]
         | 
| 132 | 
            +
                #   Optional, cannot be combined with `last` or `first`
         | 
| 133 | 
            +
                # @param first [Integer, nil]
         | 
| 134 | 
            +
                #   Optional, cannot be combined with `last` or `limit`
         | 
| 135 | 
            +
                # @param after [String, nil]
         | 
| 136 | 
            +
                #   Optional, cannot be combined with `before`
         | 
| 137 | 
            +
                # @param last [Integer, nil]
         | 
| 138 | 
            +
                #   Optional, requires `before`, cannot be combined
         | 
| 139 | 
            +
                #   with `first` or `limit`
         | 
| 140 | 
            +
                # @param before [String, nil]
         | 
| 141 | 
            +
                #   Optional, cannot be combined with `after`
         | 
| 142 | 
            +
                #
         | 
| 143 | 
            +
                # @raise [RailsCursorPagination::ParameterError]
         | 
| 144 | 
            +
                #   If parameters are combined in an invalid way
         | 
| 145 | 
            +
                def ensure_valid_params_combinations!(first, last, limit, before, after)
         | 
| 115 146 | 
             
                  if first.present? && last.present?
         | 
| 116 147 | 
             
                    raise ParameterError, '`first` cannot be combined with `last`'
         | 
| 117 148 | 
             
                  end
         | 
| 149 | 
            +
                  if first.present? && limit.present?
         | 
| 150 | 
            +
                    raise ParameterError, '`limit` cannot be combined with `first`'
         | 
| 151 | 
            +
                  end
         | 
| 152 | 
            +
                  if last.present? && limit.present?
         | 
| 153 | 
            +
                    raise ParameterError, '`limit` cannot be combined with `last`'
         | 
| 154 | 
            +
                  end
         | 
| 118 155 | 
             
                  if before.present? && after.present?
         | 
| 119 156 | 
             
                    raise ParameterError, '`before` cannot be combined with `after`'
         | 
| 120 157 | 
             
                  end
         | 
| 121 158 | 
             
                  if last.present? && before.blank?
         | 
| 122 159 | 
             
                    raise ParameterError, '`last` must be combined with `before`'
         | 
| 123 160 | 
             
                  end
         | 
| 124 | 
            -
                  if first.present? && first.negative?
         | 
| 125 | 
            -
                    raise ParameterError, "`first` cannot be negative, but was `#{first}`"
         | 
| 126 | 
            -
                  end
         | 
| 127 | 
            -
                  if last.present? && last.negative?
         | 
| 128 | 
            -
                    raise ParameterError, "`last` cannot be negative, but was `#{last}`"
         | 
| 129 | 
            -
                  end
         | 
| 130 161 |  | 
| 131 162 | 
             
                  true
         | 
| 132 163 | 
             
                end
         | 
| @@ -317,9 +348,9 @@ module RailsCursorPagination | |
| 317 348 | 
             
                #
         | 
| 318 349 | 
             
                # @return [Integer, String]
         | 
| 319 350 | 
             
                def filter_value
         | 
| 320 | 
            -
                  return  | 
| 351 | 
            +
                  return decoded_cursor.id unless custom_order_field?
         | 
| 321 352 |  | 
| 322 | 
            -
                  "#{ | 
| 353 | 
            +
                  "#{decoded_cursor.order_field_value}-#{decoded_cursor.id}"
         | 
| 323 354 | 
             
                end
         | 
| 324 355 |  | 
| 325 356 | 
             
                # Generate a cursor for the given record and ordering field. The cursor
         | 
| @@ -334,14 +365,7 @@ module RailsCursorPagination | |
| 334 365 | 
             
                # @param record [ActiveRecord] Model instance for which we want the cursor
         | 
| 335 366 | 
             
                # @return [String]
         | 
| 336 367 | 
             
                def cursor_for_record(record)
         | 
| 337 | 
            -
                   | 
| 338 | 
            -
                    if custom_order_field?
         | 
| 339 | 
            -
                      [record[@order_field], record.id]
         | 
| 340 | 
            -
                    else
         | 
| 341 | 
            -
                      record.id
         | 
| 342 | 
            -
                    end
         | 
| 343 | 
            -
             | 
| 344 | 
            -
                  Base64.strict_encode64(unencoded_cursor.to_json)
         | 
| 368 | 
            +
                  cursor_class.from_record(record: record, order_field: @order_field).encode
         | 
| 345 369 | 
             
                end
         | 
| 346 370 |  | 
| 347 371 | 
             
                # Decode the provided cursor. Either just returns the cursor's ID or in case
         | 
| @@ -350,37 +374,27 @@ module RailsCursorPagination | |
| 350 374 | 
             
                #
         | 
| 351 375 | 
             
                # @return [Integer, Array]
         | 
| 352 376 | 
             
                def decoded_cursor
         | 
| 353 | 
            -
                  memoize(:decoded_cursor)  | 
| 354 | 
            -
             | 
| 355 | 
            -
                   | 
| 356 | 
            -
                        "The given cursor `#{@cursor.inspect}` could not be decoded"
         | 
| 377 | 
            +
                  memoize(:decoded_cursor) do
         | 
| 378 | 
            +
                    cursor_class.decode(encoded_string: @cursor, order_field: @order_field)
         | 
| 379 | 
            +
                  end
         | 
| 357 380 | 
             
                end
         | 
| 358 381 |  | 
| 359 | 
            -
                #  | 
| 360 | 
            -
                #  | 
| 361 | 
            -
                # element of the tuple encoded by the cursor.
         | 
| 382 | 
            +
                # Returns the appropriate class for the cursor based on the SQL type of the
         | 
| 383 | 
            +
                # column used for ordering the relation.
         | 
| 362 384 | 
             
                #
         | 
| 363 | 
            -
                # @return [ | 
| 364 | 
            -
                def  | 
| 365 | 
            -
                   | 
| 366 | 
            -
             | 
| 367 | 
            -
             | 
| 368 | 
            -
             | 
| 385 | 
            +
                # @return [Class<RailsCursorPagination::Cursor>]
         | 
| 386 | 
            +
                def cursor_class
         | 
| 387 | 
            +
                  order_field_type = @relation
         | 
| 388 | 
            +
                                     .column_for_attribute(@order_field)
         | 
| 389 | 
            +
                                     .sql_type_metadata
         | 
| 390 | 
            +
                                     .type
         | 
| 369 391 |  | 
| 370 | 
            -
             | 
| 371 | 
            -
             | 
| 372 | 
            -
             | 
| 373 | 
            -
             | 
| 374 | 
            -
             | 
| 375 | 
            -
                # @return [Object]
         | 
| 376 | 
            -
                def decoded_cursor_field
         | 
| 377 | 
            -
                  unless decoded_cursor.is_a? Array
         | 
| 378 | 
            -
                    raise InvalidCursorError,
         | 
| 379 | 
            -
                          "The given cursor `#{@cursor}` was decoded as "\
         | 
| 380 | 
            -
                          "`#{decoded_cursor.inspect}` but could not be parsed"
         | 
| 392 | 
            +
                  case order_field_type
         | 
| 393 | 
            +
                  when :datetime
         | 
| 394 | 
            +
                    TimestampCursor
         | 
| 395 | 
            +
                  else
         | 
| 396 | 
            +
                    Cursor
         | 
| 381 397 | 
             
                  end
         | 
| 382 | 
            -
             | 
| 383 | 
            -
                  decoded_cursor.first
         | 
| 384 398 | 
             
                end
         | 
| 385 399 |  | 
| 386 400 | 
             
                # Ensure that the relation has the ID column and any potential `order_by`
         | 
| @@ -389,7 +403,8 @@ module RailsCursorPagination | |
| 389 403 | 
             
                #
         | 
| 390 404 | 
             
                # @return [ActiveRecord::Relation]
         | 
| 391 405 | 
             
                def relation_with_cursor_fields
         | 
| 392 | 
            -
                  return @relation if @relation.select_values.blank?
         | 
| 406 | 
            +
                  return @relation if @relation.select_values.blank? ||
         | 
| 407 | 
            +
                                      @relation.select_values.include?('*')
         | 
| 393 408 |  | 
| 394 409 | 
             
                  relation = @relation
         | 
| 395 410 |  | 
| @@ -453,15 +468,16 @@ module RailsCursorPagination | |
| 453 468 |  | 
| 454 469 | 
             
                    unless custom_order_field?
         | 
| 455 470 | 
             
                      next sorted_relation.where "#{id_column} #{filter_operator} ?",
         | 
| 456 | 
            -
                                                  | 
| 471 | 
            +
                                                 decoded_cursor.id
         | 
| 457 472 | 
             
                    end
         | 
| 458 473 |  | 
| 459 474 | 
             
                    sorted_relation
         | 
| 460 | 
            -
                      .where("#{@order_field} #{filter_operator} ?", | 
| 475 | 
            +
                      .where("#{@order_field} #{filter_operator} ?",
         | 
| 476 | 
            +
                             decoded_cursor.order_field_value)
         | 
| 461 477 | 
             
                      .or(
         | 
| 462 478 | 
             
                        sorted_relation
         | 
| 463 | 
            -
                          .where("#{@order_field} = ?",  | 
| 464 | 
            -
                          .where("#{id_column} #{filter_operator} ?",  | 
| 479 | 
            +
                          .where("#{@order_field} = ?", decoded_cursor.order_field_value)
         | 
| 480 | 
            +
                          .where("#{id_column} #{filter_operator} ?", decoded_cursor.id)
         | 
| 465 481 | 
             
                      )
         | 
| 466 482 | 
             
                  end
         | 
| 467 483 | 
             
                end
         | 
| @@ -0,0 +1,84 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module RailsCursorPagination
         | 
| 4 | 
            +
              # Cursor class that's used to uniquely identify a record and serialize and
         | 
| 5 | 
            +
              # deserialize this cursor so that it can be used for pagination.
         | 
| 6 | 
            +
              # This class expects the `order_field` of the record to be a timestamp and is
         | 
| 7 | 
            +
              # to be used only when sorting a
         | 
| 8 | 
            +
              class TimestampCursor < Cursor
         | 
| 9 | 
            +
                class << self
         | 
| 10 | 
            +
                  # Decode the provided encoded cursor. Returns an instance of this
         | 
| 11 | 
            +
                  # `RailsCursorPagination::Cursor` class containing both the ID and the
         | 
| 12 | 
            +
                  # ordering field value. The ordering field is expected to be a timestamp
         | 
| 13 | 
            +
                  # and is always decoded in the UTC timezone.
         | 
| 14 | 
            +
                  #
         | 
| 15 | 
            +
                  # @param encoded_string [String]
         | 
| 16 | 
            +
                  #   The encoded cursor
         | 
| 17 | 
            +
                  # @param order_field [Symbol]
         | 
| 18 | 
            +
                  #   The column that is being ordered on. It needs to be a timestamp of a
         | 
| 19 | 
            +
                  #   class that responds to `#strftime`.
         | 
| 20 | 
            +
                  # @raise [RailsCursorPagination::InvalidCursorError]
         | 
| 21 | 
            +
                  #   In case the given `encoded_string` cannot be decoded properly
         | 
| 22 | 
            +
                  # @return [RailsCursorPagination::TimestampCursor]
         | 
| 23 | 
            +
                  #   Instance of this class with a properly decoded timestamp cursor
         | 
| 24 | 
            +
                  def decode(encoded_string:, order_field:)
         | 
| 25 | 
            +
                    decoded = JSON.parse(Base64.strict_decode64(encoded_string))
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    new(
         | 
| 28 | 
            +
                      id: decoded[1],
         | 
| 29 | 
            +
                      order_field: order_field,
         | 
| 30 | 
            +
                      # Turn the order field value into a `Time` instance in UTC. A Rational
         | 
| 31 | 
            +
                      # number allows us to represent fractions of seconds, including the
         | 
| 32 | 
            +
                      # microseconds. In this way we can preserve the order of items with a
         | 
| 33 | 
            +
                      # microsecond precision.
         | 
| 34 | 
            +
                      # This also allows us to keep the size of the cursor small by using
         | 
| 35 | 
            +
                      # just a number instead of having to pass seconds and the fraction of
         | 
| 36 | 
            +
                      # seconds separately.
         | 
| 37 | 
            +
                      order_field_value: Time.at(decoded[0].to_r / (10**6)).utc
         | 
| 38 | 
            +
                    )
         | 
| 39 | 
            +
                  rescue ArgumentError, JSON::ParserError
         | 
| 40 | 
            +
                    raise InvalidCursorError,
         | 
| 41 | 
            +
                          "The given cursor `#{encoded_string}` " \
         | 
| 42 | 
            +
                          'could not be decoded to a timestamp'
         | 
| 43 | 
            +
                  end
         | 
| 44 | 
            +
                end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                # Initializes the record. Overrides `Cursor`'s initializer making all params
         | 
| 47 | 
            +
                # mandatory.
         | 
| 48 | 
            +
                #
         | 
| 49 | 
            +
                # @param id [Integer]
         | 
| 50 | 
            +
                #   The ID of the cursor record
         | 
| 51 | 
            +
                # @param order_field [Symbol]
         | 
| 52 | 
            +
                #   The column or virtual column for ordering
         | 
| 53 | 
            +
                # @param order_field_value [Object]
         | 
| 54 | 
            +
                #   The value that the +order_field+ of the record contains
         | 
| 55 | 
            +
                def initialize(id:, order_field:, order_field_value:)
         | 
| 56 | 
            +
                  super id: id,
         | 
| 57 | 
            +
                        order_field: order_field,
         | 
| 58 | 
            +
                        order_field_value: order_field_value
         | 
| 59 | 
            +
                end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                # Encodes the cursor as an array containing the timestamp as microseconds
         | 
| 62 | 
            +
                # from UNIX epoch and the id of the object
         | 
| 63 | 
            +
                #
         | 
| 64 | 
            +
                # @raise [RailsCursorPagination::ParameterError]
         | 
| 65 | 
            +
                #   The order field value needs to respond to `#strftime` to use the
         | 
| 66 | 
            +
                #   `TimestampCursor` class. Otherwise, a `ParameterError` is raised.
         | 
| 67 | 
            +
                # @return [String]
         | 
| 68 | 
            +
                def encode
         | 
| 69 | 
            +
                  unless @order_field_value.respond_to?(:strftime)
         | 
| 70 | 
            +
                    raise ParameterError,
         | 
| 71 | 
            +
                          "Could not encode #{@order_field} " \
         | 
| 72 | 
            +
                          "with value #{@order_field_value}." \
         | 
| 73 | 
            +
                          'It does not respond to #strftime. Is it a timestamp?'
         | 
| 74 | 
            +
                  end
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                  Base64.strict_encode64(
         | 
| 77 | 
            +
                    [
         | 
| 78 | 
            +
                      @order_field_value.strftime('%s%6N').to_i,
         | 
| 79 | 
            +
                      @id
         | 
| 80 | 
            +
                    ].to_json
         | 
| 81 | 
            +
                  )
         | 
| 82 | 
            +
                end
         | 
| 83 | 
            +
              end
         | 
| 84 | 
            +
            end
         | 
| @@ -148,12 +148,24 @@ | |
| 148 148 | 
             
            module RailsCursorPagination
         | 
| 149 149 | 
             
              class Error < StandardError; end
         | 
| 150 150 |  | 
| 151 | 
            +
              # Generic error that gets raised when invalid parameters are passed to the
         | 
| 152 | 
            +
              # pagination
         | 
| 153 | 
            +
              class ParameterError < Error; end
         | 
| 154 | 
            +
             | 
| 155 | 
            +
              # Error that gets raised if a cursor given as `before` or `after` cannot be
         | 
| 156 | 
            +
              # properly parsed
         | 
| 157 | 
            +
              class InvalidCursorError < ParameterError; end
         | 
| 158 | 
            +
             | 
| 151 159 | 
             
              require_relative 'rails_cursor_pagination/version'
         | 
| 152 160 |  | 
| 153 161 | 
             
              require_relative 'rails_cursor_pagination/configuration'
         | 
| 154 162 |  | 
| 155 163 | 
             
              require_relative 'rails_cursor_pagination/paginator'
         | 
| 156 164 |  | 
| 165 | 
            +
              require_relative 'rails_cursor_pagination/cursor'
         | 
| 166 | 
            +
             | 
| 167 | 
            +
              require_relative 'rails_cursor_pagination/timestamp_cursor'
         | 
| 168 | 
            +
             | 
| 157 169 | 
             
              class << self
         | 
| 158 170 | 
             
                # Allows to configure this gem. Currently supported configuration values
         | 
| 159 171 | 
             
                # are:
         | 
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: rails_cursor_pagination
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0. | 
| 4 | 
            +
              version: 0.4.0
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Nicolas Fricke
         | 
| 8 8 | 
             
            autorequire:
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date:  | 
| 11 | 
            +
            date: 2023-10-06 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: activerecord
         | 
| @@ -16,14 +16,14 @@ dependencies: | |
| 16 16 | 
             
                requirements:
         | 
| 17 17 | 
             
                - - ">="
         | 
| 18 18 | 
             
                  - !ruby/object:Gem::Version
         | 
| 19 | 
            -
                    version: ' | 
| 19 | 
            +
                    version: '6.0'
         | 
| 20 20 | 
             
              type: :runtime
         | 
| 21 21 | 
             
              prerelease: false
         | 
| 22 22 | 
             
              version_requirements: !ruby/object:Gem::Requirement
         | 
| 23 23 | 
             
                requirements:
         | 
| 24 24 | 
             
                - - ">="
         | 
| 25 25 | 
             
                  - !ruby/object:Gem::Version
         | 
| 26 | 
            -
                    version: ' | 
| 26 | 
            +
                    version: '6.0'
         | 
| 27 27 | 
             
            description: This library is an implementation of cursor pagination for ActiveRecord
         | 
| 28 28 | 
             
              relations. Where a regular limit & offset pagination has issues with items that
         | 
| 29 29 | 
             
              are being deleted from or added to the collection on previous pages, cursor pagination
         | 
| @@ -40,7 +40,9 @@ files: | |
| 40 40 | 
             
            - README.md
         | 
| 41 41 | 
             
            - lib/rails_cursor_pagination.rb
         | 
| 42 42 | 
             
            - lib/rails_cursor_pagination/configuration.rb
         | 
| 43 | 
            +
            - lib/rails_cursor_pagination/cursor.rb
         | 
| 43 44 | 
             
            - lib/rails_cursor_pagination/paginator.rb
         | 
| 45 | 
            +
            - lib/rails_cursor_pagination/timestamp_cursor.rb
         | 
| 44 46 | 
             
            - lib/rails_cursor_pagination/version.rb
         | 
| 45 47 | 
             
            homepage: https://github.com/xing/rails_cursor_pagination
         | 
| 46 48 | 
             
            licenses:
         | 
| @@ -49,6 +51,7 @@ metadata: | |
| 49 51 | 
             
              homepage_uri: https://github.com/xing/rails_cursor_pagination
         | 
| 50 52 | 
             
              source_code_uri: https://github.com/xing/rails_cursor_pagination
         | 
| 51 53 | 
             
              changelog_uri: https://github.com/xing/rails_cursor_pagination/blob/master/CHANGELOG.md
         | 
| 54 | 
            +
              rubygems_mfa_required: 'true'
         | 
| 52 55 | 
             
            post_install_message:
         | 
| 53 56 | 
             
            rdoc_options: []
         | 
| 54 57 | 
             
            require_paths:
         | 
| @@ -57,14 +60,14 @@ required_ruby_version: !ruby/object:Gem::Requirement | |
| 57 60 | 
             
              requirements:
         | 
| 58 61 | 
             
              - - ">="
         | 
| 59 62 | 
             
                - !ruby/object:Gem::Version
         | 
| 60 | 
            -
                  version: 2. | 
| 63 | 
            +
                  version: 2.7.0
         | 
| 61 64 | 
             
            required_rubygems_version: !ruby/object:Gem::Requirement
         | 
| 62 65 | 
             
              requirements:
         | 
| 63 66 | 
             
              - - ">="
         | 
| 64 67 | 
             
                - !ruby/object:Gem::Version
         | 
| 65 68 | 
             
                  version: '0'
         | 
| 66 69 | 
             
            requirements: []
         | 
| 67 | 
            -
            rubygems_version: 3. | 
| 70 | 
            +
            rubygems_version: 3.2.33
         | 
| 68 71 | 
             
            signing_key:
         | 
| 69 72 | 
             
            specification_version: 4
         | 
| 70 73 | 
             
            summary: Add cursor pagination to your ActiveRecord backed application.
         |