diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
commit | f64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch) | |
tree | a2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /lib/gitlab/pagination | |
parent | bfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff) | |
download | gitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'lib/gitlab/pagination')
-rw-r--r-- | lib/gitlab/pagination/keyset/column_order_definition.rb | 224 | ||||
-rw-r--r-- | lib/gitlab/pagination/keyset/order.rb | 248 |
2 files changed, 472 insertions, 0 deletions
diff --git a/lib/gitlab/pagination/keyset/column_order_definition.rb b/lib/gitlab/pagination/keyset/column_order_definition.rb new file mode 100644 index 00000000000..0c8ec02a56b --- /dev/null +++ b/lib/gitlab/pagination/keyset/column_order_definition.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module Gitlab + module Pagination + module Keyset + # This class stores information for one column (or SQL expression) which can be used in an + # ORDER BY SQL clasue. + # The goal of this class is to encapsulate all the metadata in one place which are needed to + # make keyset pagination work in a generalized way. + # + # == Arguments + # + # **order expression** (Arel::Nodes::Node | String) + # + # The actual SQL expression for the ORDER BY clause. + # + # Examples: + # # Arel column order definition + # Project.arel_table[:id].asc # ORDER BY projects.id ASC + # + # # Arel expression, calculated order definition + # Arel::Nodes::NamedFunction.new("COALESCE", [Project.arel_table[:issue_count].asc, 0]).asc # ORDER BY COALESCE(projects.issue_count, 0) + # + # # Another Arel expression + # Arel::Nodes::Multiplication(Issue.arel_table[:weight], Issue.arel_table[:time_spent]).desc + # + # # Raw string order definition + # 'issues.type DESC NULLS LAST' + # + # **column_expression** (Arel::Nodes::Node | String) + # + # Expression for the database column or an expression. This value will be used with logical operations (>, <, =, !=) + # when building the database query for the next page. + # + # Examples: + # # Arel column reference + # Issue.arel_table[:title] + # + # # Calculated value + # Arel::Nodes::Multiplication(Issue.arel_table[:weight], Issue.arel_table[:time_spent]) + # + # **attribute_name** (String | Symbol) + # + # An attribute on the loaded ActiveRecord model where the value can be obtained. + # + # Examples: + # # Simple attribute definition + # attribute_name = :title + # + # # Later on this attribute will be used like this: + # my_record = Issue.find(x) + # value = my_record[attribute_name] # reads data from the title column + # + # # Calculated value based on an Arel or raw SQL expression + # + # attribute_name = :lowercase_title + # + # # `lowercase_title` is not is not a table column therefore we need to make sure it's available in the `SELECT` clause + # + # my_record = Issue.select(:id, 'LOWER(title) as lowercase_title').last + # value = my_record[:lowercase_title] + # + # **distinct** + # + # Boolean value. + # + # Tells us whether the database column contains only distinct values. If the column is covered by + # a unique index then set to true. + # + # **nullable** (:not_nullable | :nulls_last | :nulls_first) + # + # Tells us whether the database column is nullable or not. This information can be + # obtained from the DB schema. + # + # If the column is not nullable, set this attribute to :not_nullable. + # + # If the column is nullable, then additional information is needed. Based on the ordering, the null values + # will show up at the top or at the bottom of the resultset. + # + # Examples: + # # Nulls are showing up at the top (for example: ORDER BY column ASC): + # nullable = :nulls_first + # + # # Nulls are showing up at the bottom (for example: ORDER BY column DESC): + # nullable = :nulls_last + # + # **order_direction** + # + # :asc or :desc + # + # Note: this is an optional attribute, the value will be inferred from the order_expression. + # Sometimes it's not possible to infer the order automatically. In this case an exception will be + # raised (when the query is executed). If the reverse order cannot be computed, it must be provided explicitly. + # + # **reversed_order_expression** + # + # The reversed version of the order_expression. + # + # A ColumnOrderDefinition object is able to reverse itself which is used when paginating backwards. + # When a complex order_expression is provided (raw string), then reversing the order automatically + # is not possible. In this case an exception will be raised. + # + # Example: + # + # order_expression = Project.arel_table[:id].asc + # reversed_order_expression = Project.arel_table[:id].desc + # + # **add_to_projections** + # + # Set to true if the column is not part of the queried table. (Not part of SELECT *) + # + # Example: + # + # - When the order is a calculated expression or the column is in another table (JOIN-ed) + # + # If the add_to_projections is true, the query builder will automatically add the column to the SELECT values + class ColumnOrderDefinition + REVERSED_ORDER_DIRECTIONS = { asc: :desc, desc: :asc }.freeze + REVERSED_NULL_POSITIONS = { nulls_first: :nulls_last, nulls_last: :nulls_first }.freeze + AREL_ORDER_CLASSES = { Arel::Nodes::Ascending => :asc, Arel::Nodes::Descending => :desc }.freeze + ALLOWED_NULLABLE_VALUES = [:not_nullable, :nulls_first, :nulls_last].freeze + + attr_reader :attribute_name, :column_expression, :order_expression, :add_to_projections + + def initialize(attribute_name:, order_expression:, column_expression: nil, reversed_order_expression: nil, nullable: :not_nullable, distinct: true, order_direction: nil, add_to_projections: false) + @attribute_name = attribute_name + @order_expression = order_expression + @column_expression = column_expression || calculate_column_expression(order_expression) + @distinct = distinct + @reversed_order_expression = reversed_order_expression || calculate_reversed_order(order_expression) + @nullable = parse_nullable(nullable, distinct) + @order_direction = parse_order_direction(order_expression, order_direction) + @add_to_projections = add_to_projections + end + + def reverse + self.class.new( + attribute_name: attribute_name, + column_expression: column_expression, + order_expression: reversed_order_expression, + reversed_order_expression: order_expression, + nullable: not_nullable? ? :not_nullable : REVERSED_NULL_POSITIONS[nullable], + distinct: distinct, + order_direction: REVERSED_ORDER_DIRECTIONS[order_direction] + ) + end + + def ascending_order? + order_direction == :asc + end + + def descending_order? + order_direction == :desc + end + + def nulls_first? + nullable == :nulls_first + end + + def nulls_last? + nullable == :nulls_last + end + + def not_nullable? + nullable == :not_nullable + end + + def nullable? + !not_nullable? + end + + def distinct? + distinct + end + + private + + attr_reader :reversed_order_expression, :nullable, :distinct, :order_direction + + def calculate_reversed_order(order_expression) + unless AREL_ORDER_CLASSES.has_key?(order_expression.class) # Arel can reverse simple orders + raise "Couldn't determine reversed order for `#{order_expression}`, please provide the `reversed_order_expression` parameter." + end + + order_expression.reverse + end + + def calculate_column_expression(order_expression) + if order_expression.respond_to?(:expr) + order_expression.expr + else + raise("Couldn't calculate the column expression. Please pass an ARel node as the order_expression, not a string.") + end + end + + def parse_order_direction(order_expression, order_direction) + transformed_order_direction = if order_direction.nil? && AREL_ORDER_CLASSES[order_expression.class] + AREL_ORDER_CLASSES[order_expression.class] + elsif order_direction.present? + order_direction.to_s.downcase.to_sym + end + + unless REVERSED_ORDER_DIRECTIONS.has_key?(transformed_order_direction) + raise "Invalid or missing `order_direction` (value: #{order_direction}) was given, the allowed values are: :asc or :desc" + end + + transformed_order_direction + end + + def parse_nullable(nullable, distinct) + if ALLOWED_NULLABLE_VALUES.exclude?(nullable) + raise "Invalid `nullable` is given (value: #{nullable}), the allowed values are: #{ALLOWED_NULLABLE_VALUES.join(', ')}" + end + + if nullable != :not_nullable && distinct + raise 'Invalid column definition, `distinct` and `nullable` columns are not allowed at the same time' + end + + nullable + end + end + end + end +end diff --git a/lib/gitlab/pagination/keyset/order.rb b/lib/gitlab/pagination/keyset/order.rb new file mode 100644 index 00000000000..e8e68a5c4a5 --- /dev/null +++ b/lib/gitlab/pagination/keyset/order.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +module Gitlab + module Pagination + module Keyset + # This class is a special ORDER BY clause which is compatible with ActiveRecord. It helps + # building keyset paginated queries. + # + # In ActiveRecord we use the `order()` method which will generate the `ORDER BY X` SQL clause + # + # Project.where(active: true).order(id: :asc) + # + # # Or + # + # Project.where(active: true).order(created_at: :asc, id: desc) + # + # Gitlab::Pagination::Keyset::Order class encapsulates more information about the order columns + # in order to implement keyset pagination in a generic way + # + # - Extract values from a record (usually the last item of the previous query) + # - Build query conditions based on the column configuration + # + # Example 1: Order by primary key + # + # # Simple order definition for the primary key as an ActiveRecord scope + # scope :id_asc_ordered, -> { + # keyset_order = Gitlab::Pagination::Keyset::Order.build([ + # Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + # attribute: :id, + # order_expression: Project.arel_table[:id].asc + # ) + # ]) + # + # reorder(keyset_order) + # } + # + # # ... Later in the application code: + # + # # Compatible with ActiveRecord's `order()` method + # page1 = Project.where(active: true).id_asc_ordered.limit(5) + # keyset_order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(page1) + # + # last_record = page1.last + # cursor_values = keyset_order.cursor_attributes_for_node(last_record) # { id: x } + # + # page2 = keyset_order.apply_cursor_conditions(Project.where(active: true).id_asc_ordered, cursor_values).limit(5) + # + # last_record = page2.last + # cursor_values = keyset_order.cursor_attributes_for_node(last_record) + # + # page3 = keyset_order.apply_cursor_conditions(Project.where(active: true).id_asc_ordered, cursor_values).limit(5) + # + # Example 2: Order by creation time and primary key (primary key is the tie breaker) + # + # scope :created_at_ordered, -> { + # keyset_order = Gitlab::Pagination::Keyset::Order.build([ + # Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + # attribute: :created_at, + # column_expression: Project.arel_table[:created_at], + # order_expression: Project.arel_table[:created_at].asc, + # distinct: false, # values in the column are not unique + # nullable: :nulls_last # we might see NULL values (bottom) + # ), + # Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + # attribute: :id, + # order_expression: Project.arel_table[:id].asc + # ) + # ]) + # + # reorder(keyset_order) + # } + # + class Order < Arel::Nodes::SqlLiteral + attr_reader :column_definitions + + def initialize(column_definitions:) + @column_definitions = column_definitions + + super(to_sql_literal(@column_definitions)) + end + + # Tells whether the given ActiveRecord::Relation has keyset ordering + def self.keyset_aware?(scope) + scope.order_values.first.is_a?(self) && scope.order_values.one? + end + + def self.extract_keyset_order_object(scope) + scope.order_values.first + end + + def self.build(column_definitions) + new(column_definitions: column_definitions) + end + + def cursor_attributes_for_node(node) + column_definitions.each_with_object({}) do |column_definition, hash| + field_value = node[column_definition.attribute_name] + hash[column_definition.attribute_name] = if field_value.is_a?(Time) + field_value.strftime('%Y-%m-%d %H:%M:%S.%N %Z') + elsif field_value.nil? + nil + else + field_value.to_s + end + end + end + + # This methods builds the conditions for the keyset pagination + # + # Example: + # + # |created_at|id| + # |----------|--| + # |2020-01-01| 1| + # | null| 2| + # | null| 3| + # |2020-02-01| 4| + # + # Note: created_at is not distinct and nullable + # Order `ORDER BY created_at DESC, id DESC` + # + # We get the following cursor values from the previous page: + # { id: 4, created_at: '2020-02-01' } + # + # To get the next rows, we need to build the following conditions: + # + # (created_at = '2020-02-01' AND id < 4) OR (created_at < '2020-01-01') + # + # DESC ordering ensures that NULL values are on top so we don't need conditions for NULL values + # + # Another cursor example: + # { id: 3, created_at: nil } + # + # To get the next rows, we need to build the following conditions: + # + # (id < 3 AND created_at IS NULL) OR (created_at IS NOT NULL) + def build_where_values(values) + return if values.blank? + + verify_incoming_values!(values) + + where_values = [] + + reversed_column_definitions = column_definitions.reverse + reversed_column_definitions.each_with_index do |column_definition, i| + value = values[column_definition.attribute_name] + + conditions_for_column(column_definition, value).each do |condition| + column_definitions_after_index = reversed_column_definitions.last(column_definitions.reverse.size - i - 1) + + equal_conditon_for_rest = column_definitions_after_index.map do |definition| + definition.column_expression.eq(values[definition.attribute_name]) + end + + where_values << Arel::Nodes::Grouping.new(Arel::Nodes::And.new([condition, *equal_conditon_for_rest].compact)) + end + end + + build_or_query(where_values) + end + + # rubocop: disable CodeReuse/ActiveRecord + def apply_cursor_conditions(scope, values = {}) + scope = apply_custom_projections(scope) + scope.where(build_where_values(values)) + end + # rubocop: enable CodeReuse/ActiveRecord + + def reversed_order + self.class.build(column_definitions.map(&:reverse)) + end + + private + + # Adds extra columns to the SELECT clause + def apply_custom_projections(scope) + additional_projections = column_definitions.select(&:add_to_projections).map do |column_definition| + # avoid mutating the original column_expression + column_definition.column_expression.dup.as(column_definition.attribute_name).to_sql + end + + scope = scope.select(*scope.arel.projections, *additional_projections) if additional_projections + scope + end + + def conditions_for_column(column_definition, value) + conditions = [] + # Depending on the order, build a query condition fragment for taking the next rows + if column_definition.distinct? || (!column_definition.distinct? && value.present?) + conditions << compare_column_with_value(column_definition, value) + end + + # When the column is nullable, additional conditions for NULL a NOT NULL values are necessary. + # This depends on the position of the nulls (top or bottom of the resultset). + if column_definition.nulls_first? && value.blank? + conditions << column_definition.column_expression.not_eq(nil) + elsif column_definition.nulls_last? && value.present? + conditions << column_definition.column_expression.eq(nil) + end + + conditions + end + + def compare_column_with_value(column_definition, value) + if column_definition.descending_order? + column_definition.column_expression.lt(value) + else + column_definition.column_expression.gt(value) + end + end + + def build_or_query(expressions) + or_expression = expressions.reduce { |or_expression, expression| Arel::Nodes::Or.new(or_expression, expression) } + + Arel::Nodes::Grouping.new(or_expression) + end + + def to_sql_literal(column_definitions) + column_definitions.map do |column_definition| + if column_definition.order_expression.respond_to?(:to_sql) + column_definition.order_expression.to_sql + else + column_definition.order_expression.to_s + end + end.join(', ') + end + + def verify_incoming_values!(values) + value_keys = values.keys.map(&:to_s) + order_attrbute_names = column_definitions.map(&:attribute_name).map(&:to_s) + missing_items = order_attrbute_names - value_keys + extra_items = value_keys - order_attrbute_names + + if missing_items.any? || extra_items.any? + error_text = ['Incorrect cursor values were given'] + + error_text << "Extra items: #{extra_items.join(', ')}" if extra_items.any? + error_text << "Missing items: #{missing_items.join(', ')}" if missing_items.any? + + error_text.compact + + raise error_text.join('. ') + end + end + end + end + end +end |