summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorAndreas Brandl <abrandl@gitlab.com>2018-04-25 16:42:49 +0200
committerAndreas Brandl <abrandl@gitlab.com>2018-05-21 18:02:41 +0200
commitf8aee5b0866df2a58522162cb348824d7e1fb3f0 (patch)
tree2a1e8499f5cde703137c3aea0e81f7c32373120a /lib
parentcc1d141127e1f3fa4f3e454460df3238cd062417 (diff)
downloadgitlab-ce-f8aee5b0866df2a58522162cb348824d7e1fb3f0.tar.gz
Add keyset pagination for API calls.
Closes #45756.
Diffstat (limited to 'lib')
-rw-r--r--lib/api/helpers/pagination.rb170
1 files changed, 165 insertions, 5 deletions
diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb
index cf9501c31fe..7f5f46f66be 100644
--- a/lib/api/helpers/pagination.rb
+++ b/lib/api/helpers/pagination.rb
@@ -2,15 +2,175 @@ module API
module Helpers
module Pagination
def paginate(relation)
- DefaultPaginationStrategy.new(self).paginate(relation)
+ strategy = if params[:pagination] == 'keyset'
+ KeysetPaginationStrategy
+ else
+ DefaultPaginationStrategy
+ end
+
+ strategy.new(self).paginate(relation)
+ end
+
+ class KeysetPaginationInfo
+ attr_reader :relation, :request_context
+
+ def initialize(relation, request_context)
+ # This is because it's rather complex to support multiple values with possibly different sort directions
+ # (and we don't need this in the API)
+ if relation.order_values.size > 1
+ raise "Pagination only supports ordering by a single column." \
+ "The following columns were given: #{relation.order_values.map { |v| v.expr.name }}"
+ end
+
+ @relation = relation
+ @request_context = request_context
+ end
+
+ def fields
+ keys.zip(values).reject { |_, v| v.nil? }.to_h
+ end
+
+ def column_for_order_by(relation)
+ relation.order_values.first&.expr&.name
+ end
+
+ # Sort direction (`:asc` or `:desc`)
+ def sort
+ @sort ||= if order_by_primary_key?
+ # Default order is by id DESC
+ :desc
+ else
+ # API defaults to DESC order if param `sort` not present
+ request_context.params[:sort]&.to_sym || :desc
+ end
+ end
+
+ # Do we only sort by primary key?
+ def order_by_primary_key?
+ keys.size == 1 && keys.first == primary_key
+ end
+
+ def primary_key
+ relation.model.primary_key.to_sym
+ end
+
+ def sort_ascending?
+ sort == :asc
+ end
+
+ # Build hash of request parameters for a given record (relevant to pagination)
+ def params_for(record)
+ return {} unless record
+
+ keys.each_with_object({}) do |key, h|
+ h["ks_prev_#{key}".to_sym] = record.attributes[key.to_s]
+ end
+ end
+
+ private
+
+ # All values present in request parameters that correspond to #keys.
+ def values
+ @values ||= keys.map do |key|
+ request_context.params["ks_prev_#{key}".to_sym]
+ end
+ end
+
+ # All keys relevant to pagination.
+ # This always includes the primary key. Optionally, the `order_by` key is prepended.
+ def keys
+ @keys ||= [column_for_order_by(relation), primary_key].compact.uniq
+ end
+ end
+
+ class KeysetPaginationStrategy
+ attr_reader :request_context
+ delegate :params, :header, :request, to: :request_context
+
+ def initialize(request_context)
+ @request_context = request_context
+ end
+
+ def paginate(relation)
+ pagination = KeysetPaginationInfo.new(relation, request_context)
+
+ paged_relation = relation.limit(per_page)
+
+ if conds = conditions(pagination)
+ paged_relation = paged_relation.where(*conds)
+ end
+
+ # In all cases: sort by primary key (possibly in addition to another sort column)
+ paged_relation = paged_relation.order(pagination.primary_key => pagination.sort)
+
+ add_default_pagination_headers
+
+ if last_record = paged_relation.last
+ next_page_params = pagination.params_for(last_record)
+ add_navigation_links(next_page_params)
+ end
+
+ paged_relation
+ end
+
+ private
+
+ def conditions(pagination)
+ fields = pagination.fields
+
+ return nil if fields.empty?
+
+ placeholder = fields.map { '?' }
+
+ comp = if pagination.sort_ascending?
+ '>'
+ else
+ '<'
+ end
+
+ [
+ # Row value comparison:
+ # (A, B) < (a, b) <=> (A < a) OR (A = a AND B < b)
+ # <=> A <= a AND ((A < a) OR (A = a AND B < b))
+ "(#{fields.keys.join(',')}) #{comp} (#{placeholder.join(',')})",
+ *fields.values
+ ]
+ end
+
+ def per_page
+ params[:per_page]
+ end
+
+ def add_default_pagination_headers
+ header 'X-Per-Page', per_page.to_s
+ end
+
+ def add_navigation_links(next_page_params)
+ header 'X-Next-Page', page_href(next_page_params)
+ header 'Link', link_for('next', next_page_params)
+ end
+
+ def page_href(next_page_params)
+ request_url = request.url.split('?').first
+ request_params = params.dup
+ request_params[:per_page] = per_page
+
+ request_params.merge!(next_page_params) if next_page_params
+
+ "#{request_url}?#{request_params.to_query}"
+ end
+
+ def link_for(rel, next_page_params)
+ %(<#{page_href(next_page_params)}>; rel="#{rel}")
+ end
end
class DefaultPaginationStrategy
- attr_reader :ctx
- delegate :params, :header, :request, to: :ctx
+ attr_reader :request_context
+ delegate :params, :header, :request, to: :request_context
- def initialize(ctx)
- @ctx = ctx
+ def initialize(request_context)
+ @request_context = request_context
end
def paginate(relation)