From c1740e9a77c4b84ca8bbe5d5474378e3ae21e4db Mon Sep 17 00:00:00 2001 From: Brett Walker <bwalker@gitlab.com> Date: Thu, 26 Sep 2019 16:18:06 -0500 Subject: [PATCH] Teach keyset_connection multiple attribute order by changing the cursor into json and handling a NULL condition correctly. --- ee/app/models/design_management/design.rb | 2 +- lib/gitlab/graphql/connections.rb | 2 +- .../keyset/conditions/base_condition.rb | 44 +++ .../keyset/conditions/not_null_condition.rb | 59 ++++ .../keyset/conditions/null_condition.rb | 45 +++ .../graphql/connections/keyset/connection.rb | 137 +++++++++ .../graphql/connections/keyset/order_info.rb | 66 ++++ .../connections/keyset/query_builder.rb | 70 +++++ .../graphql/connections/keyset_connection.rb | 85 ------ spec/graphql/features/authorization_spec.rb | 3 +- spec/graphql/gitlab_schema_spec.rb | 2 +- .../conditions/not_null_condition_spec.rb | 56 ++++ .../keyset/conditions/null_condition_spec.rb | 42 +++ .../connections/keyset/connection_spec.rb | 289 ++++++++++++++++++ .../connections/keyset/order_info_spec.rb | 61 ++++ .../connections/keyset/query_builder_spec.rb | 108 +++++++ .../connections/keyset_connection_spec.rb | 117 ------- 17 files changed, 982 insertions(+), 206 deletions(-) create mode 100644 lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb create mode 100644 lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb create mode 100644 lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb create mode 100644 lib/gitlab/graphql/connections/keyset/connection.rb create mode 100644 lib/gitlab/graphql/connections/keyset/order_info.rb create mode 100644 lib/gitlab/graphql/connections/keyset/query_builder.rb delete mode 100644 lib/gitlab/graphql/connections/keyset_connection.rb create mode 100644 spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb create mode 100644 spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb create mode 100644 spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb create mode 100644 spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb create mode 100644 spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb delete mode 100644 spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb diff --git a/ee/app/models/design_management/design.rb b/ee/app/models/design_management/design.rb index 010c349aa2cf..d4f2fce9fd1c 100644 --- a/ee/app/models/design_management/design.rb +++ b/ee/app/models/design_management/design.rb @@ -46,7 +46,7 @@ class Design < ApplicationRecord join = designs.join(actions) .on(actions[:design_id].eq(designs[:id])) - joins(join.join_sources).where(actions[:event].not_eq(deletion)) + joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id) end # Scope called by our REST API to avoid N+1 problems diff --git a/lib/gitlab/graphql/connections.rb b/lib/gitlab/graphql/connections.rb index fbccdfa7b081..64f7a268b7e0 100644 --- a/lib/gitlab/graphql/connections.rb +++ b/lib/gitlab/graphql/connections.rb @@ -6,7 +6,7 @@ module Connections def self.use(_schema) GraphQL::Relay::BaseConnection.register_connection_implementation( ActiveRecord::Relation, - Gitlab::Graphql::Connections::KeysetConnection + Gitlab::Graphql::Connections::Keyset::Connection ) end end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb new file mode 100644 index 000000000000..c017ff06c6a2 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/base_condition.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class BaseCondition + def initialize(arel_table, names, values, operator, before_or_after) + @arel_table, @names, @values, @operator, @before_or_after = arel_table, names, values, operator, before_or_after + end + + def build + raise NotImplementedError + end + + private + + attr_reader :arel_table, :names, :values, :operator, :before_or_after + + def assemble_conditions(conditions) + conditions.join + end + + def table_condition(attribute, value, operator) + case operator + when '>' + arel_table[attribute].gt(value) + when '<' + arel_table[attribute].lt(value) + when '=' + arel_table[attribute].eq(value) + when 'is_null' + arel_table[attribute].eq(nil) + when 'is_not_null' + arel_table[attribute].not_eq(nil) + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb new file mode 100644 index 000000000000..8e6b16cfa6e6 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class NotNullCondition < BaseCondition + def build + conditions = [] + conditions << first_attribute_condition + + # If there is only one order field, we can assume it + # does not contain NULLs, and don't need additional + # conditions + unless names.count == 1 + conditions << second_attribute_condition + conditions << final_condition + end + + assemble_conditions(conditions) + end + + private + + # ex: "(relative_position > 23)" + def first_attribute_condition + <<~SQL + (#{table_condition(names.first, values.first, operator.first).to_sql}) + SQL + end + + # ex: " OR (relative_position = 23 AND id > 500)" + def second_attribute_condition + condition = <<~SQL + OR ( + #{table_condition(names.first, values.first, '=').to_sql} + AND + #{table_condition(names[1], values[1], operator[1]).to_sql} + ) + SQL + + condition + end + + # ex: " OR (relative_position IS NULL)" + def final_condition + if before_or_after == :after + <<~SQL + OR (#{table_condition(names.first, nil, 'is_null').to_sql}) + SQL + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb b/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb new file mode 100644 index 000000000000..1f3f684a5706 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/conditions/null_condition.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + module Conditions + class NullCondition < BaseCondition + def build + conditions = [] + conditions << first_attribute_condition + conditions << final_condition + + assemble_conditions(conditions) + end + + private + + # ex: "(relative_position IS NULL AND id > 500)" + def first_attribute_condition + condition = <<~SQL + ( + #{table_condition(names.first, nil, 'is_null').to_sql} + AND + #{table_condition(names[1], values[1], operator[1]).to_sql} + ) + SQL + + condition + end + + # ex: " OR (relative_position IS NOT NULL)" + def final_condition + if before_or_after == :before + <<~SQL + OR (#{table_condition(names.first, nil, 'is_not_null').to_sql}) + SQL + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/connection.rb b/lib/gitlab/graphql/connections/keyset/connection.rb new file mode 100644 index 000000000000..79f5a9027dbf --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/connection.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Keyset::Connection provides cursor based pagination, to avoid using OFFSET. +# It basically sorts / filters using WHERE sorting_value > cursor. +# We do this for performance reasons (https://gitlab.com/gitlab-org/gitlab-ce/issues/45756), +# as well as for having stable pagination +# https://graphql-ruby.org/pro/cursors.html#whats-the-difference +# https://coderwall.com/p/lkcaag/pagination-you-re-probably-doing-it-wrong +# +# It currently supports sorting on two columns, but the last column must +# be the primary key. For example +# +# Issue.order(created_at: :asc).order(:id) +# Issue.order(due_date: :asc).order(:id) +# +# It will tolerate non-attribute ordering, but only attributes determine the cursor. +# For example, this is legitimate: +# +# Issue.order('issues.due_date IS NULL').order(due_date: :asc).order(:id) +# +# but anything more complex has a chance of not working. +# +module Gitlab + module Graphql + module Connections + module Keyset + class Connection < GraphQL::Relay::BaseConnection + include Gitlab::Utils::StrongMemoize + + def cursor_from_node(node) + encoded_json_from_ordering(node) + end + + def sliced_nodes + @sliced_nodes ||= + begin + OrderInfo.validate_ordering(ordered_nodes, order_list) + + sliced = ordered_nodes + sliced = slice_nodes(sliced, before, :before) if before.present? + sliced = slice_nodes(sliced, after, :after) if after.present? + + sliced + end + end + + def paged_nodes + # These are the nodes that will be loaded into memory for rendering + # So we're ok loading them into memory here as that's bound to happen + # anyway. Having them ready means we can modify the result while + # rendering the fields. + @paged_nodes ||= load_paged_nodes.to_a + end + + private + + def load_paged_nodes + if first && last + raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both") + end + + if last + sliced_nodes.last(limit_value) + else + sliced_nodes.limit(limit_value) # rubocop: disable CodeReuse/ActiveRecord + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def slice_nodes(sliced, encoded_cursor, before_or_after) + decoded_cursor = ordering_from_encoded_json(encoded_cursor) + builder = QueryBuilder.new(arel_table, order_list, decoded_cursor, before_or_after) + ordering = builder.conditions + + sliced.where(*ordering).where.not(id: decoded_cursor['id']) + end + # rubocop: enable CodeReuse/ActiveRecord + + def limit_value + @limit_value ||= [first, last, max_page_size].compact.min + end + + def ordered_nodes + strong_memoize(:order_nodes) do + list = OrderInfo.build_order_list(nodes) + + # ensure there is a primary key ordering + if list&.last&.attribute_name != nodes.primary_key + nodes.order(arel_table[nodes.primary_key].desc) # rubocop: disable CodeReuse/ActiveRecord + else + nodes + end + end + end + + def order_list + strong_memoize(:order_list) do + OrderInfo.build_order_list(ordered_nodes) + end + end + + def arel_table + nodes.arel_table + end + + # Storing the current order values in the cursor allows us to + # make an intelligent decision on handling NULL values. + # Otherwise we would either need to fetch the record first, + # or fetch it in the SQL, significantly complicating it. + def encoded_json_from_ordering(node) + ordering = { 'id' => node[:id].to_s } + + order_list.each do |field| + field_name = field.attribute_name + ordering[field_name] = node[field_name].to_s + end + + encode(ordering.to_json) + end + + def ordering_from_encoded_json(cursor) + JSON.parse(decode(cursor)) + rescue JSON::ParserError + # for the transition period where a client might request using an + # old style cursor. Once removed, make it an error: + # raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor" + # TODO can be removed in next release + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + field_name = order_list.first.attribute_name + + { field_name => decode(cursor) } + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/order_info.rb b/lib/gitlab/graphql/connections/keyset/order_info.rb new file mode 100644 index 000000000000..e7fb31847405 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/order_info.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + class OrderInfo + def initialize(order_value) + @order_value = order_value + end + + def attribute_name + order_value.expr.name + end + + def operator_for(before_or_after) + case before_or_after + when :before + sort_direction == :asc ? '<' : '>' + when :after + sort_direction == :asc ? '>' : '<' + end + end + + # Only allow specific node types. For example ignore String nodes + def self.build_order_list(relation) + order_list = relation.order_values.select do |value| + value.is_a?(Arel::Nodes::Ascending) || value.is_a?(Arel::Nodes::Descending) + end + + order_list.map { |info| OrderInfo.new(info) } + end + + def self.validate_ordering(relation, order_list) + if order_list.empty? + raise ::ArgumentError.new('A minimum of 1 ordering field is required') + end + + if order_list.count > 2 + raise Gitlab::Graphql::Errors::ArgumentError.new('A maximum of 2 ordering fields are allowed') + end + + # make sure the last ordering field is non-nullable + attribute_name = order_list.last&.attribute_name + + if relation.columns_hash[attribute_name].null + raise Gitlab::Graphql::Errors::ArgumentError.new("Column `#{attribute_name}` must not allow NULL") + end + + if order_list.last.attribute_name != relation.primary_key + raise Gitlab::Graphql::Errors::ArgumentError.new("Last ordering field must be the primary key, `#{relation.primary_key}`") + end + end + + private + + attr_reader :order_value + + def sort_direction + order_value.direction + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset/query_builder.rb b/lib/gitlab/graphql/connections/keyset/query_builder.rb new file mode 100644 index 000000000000..b0bfe3887127 --- /dev/null +++ b/lib/gitlab/graphql/connections/keyset/query_builder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module Connections + module Keyset + class QueryBuilder + def initialize(arel_table, order_list, decoded_cursor, before_or_after) + @arel_table, @order_list, @decoded_cursor, @before_or_after = arel_table, order_list, decoded_cursor, before_or_after + + if order_list.empty? + raise Gitlab::Graphql::Errors::ArgumentError.new('No ordering scopes have been supplied') + end + end + + # Based on whether the main field we're ordering on is NULL in the + # cursor, we can more easily target our query condition. + # We assume that the last ordering field is unique, meaning + # it will not contain NULLs. + # We currently only support two ordering fields. + # + # Example of the conditions for + # relation: Issue.order(relative_position: :asc).order(id: :asc) + # after cursor: relative_position: 1500, id: 500 + # + # when cursor[relative_position] is not NULL + # + # ("issues"."relative_position" > 1500) + # OR ( + # "issues"."relative_position" = 1500 + # AND + # "issues"."id" > 500 + # ) + # OR ("issues"."relative_position" IS NULL) + # + # when cursor[relative_position] is NULL + # + # "issues"."relative_position" IS NULL + # AND + # "issues"."id" > 500 + # + def conditions + attr_names = order_list.map { |field| field.attribute_name } + attr_values = attr_names.map { |name| decoded_cursor[name] } + + if attr_names.count == 1 && attr_values.first.nil? + raise Gitlab::Graphql::Errors::ArgumentError.new('Only one sortable scope and nil was supplied') + end + + operators = comparison_operators + + if attr_names.count == 1 || attr_values.first.presence + Keyset::Conditions::NotNullCondition.new(arel_table, attr_names, attr_values, operators, before_or_after).build + else + Keyset::Conditions::NullCondition.new(arel_table, attr_names, attr_values, operators, before_or_after).build + end + end + + private + + attr_reader :arel_table, :order_list, :decoded_cursor, :before_or_after + + def comparison_operators + order_list.map { |field| field.operator_for(before_or_after) } + end + end + end + end + end +end diff --git a/lib/gitlab/graphql/connections/keyset_connection.rb b/lib/gitlab/graphql/connections/keyset_connection.rb deleted file mode 100644 index 715963a44c1d..000000000000 --- a/lib/gitlab/graphql/connections/keyset_connection.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Graphql - module Connections - class KeysetConnection < GraphQL::Relay::BaseConnection - def cursor_from_node(node) - encode(node[order_field].to_s) - end - - # rubocop: disable CodeReuse/ActiveRecord - def sliced_nodes - @sliced_nodes ||= - begin - sliced = nodes - - sliced = sliced.where(before_slice) if before.present? - sliced = sliced.where(after_slice) if after.present? - - sliced - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def paged_nodes - # These are the nodes that will be loaded into memory for rendering - # So we're ok loading them into memory here as that's bound to happen - # anyway. Having them ready means we can modify the result while - # rendering the fields. - @paged_nodes ||= load_paged_nodes.to_a - end - - private - - def load_paged_nodes - if first && last - raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both") - end - - if last - sliced_nodes.last(limit_value) - else - sliced_nodes.limit(limit_value) # rubocop: disable CodeReuse/ActiveRecord - end - end - - def before_slice - if sort_direction == :asc - table[order_field].lt(decode(before)) - else - table[order_field].gt(decode(before)) - end - end - - def after_slice - if sort_direction == :asc - table[order_field].gt(decode(after)) - else - table[order_field].lt(decode(after)) - end - end - - def limit_value - @limit_value ||= [first, last, max_page_size].compact.min - end - - def table - nodes.arel_table - end - - def order_info - @order_info ||= nodes.order_values.first - end - - def order_field - @order_field ||= order_info&.expr&.name || nodes.primary_key - end - - def sort_direction - @order_direction ||= order_info&.direction || :desc - end - end - end - end -end diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index 9a60ff3b78c3..7ad6a622b4b6 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -259,7 +259,8 @@ let(:project_type) do |type| type_factory do |type| type.graphql_name 'FakeProjectType' - type.field :test_issues, issue_type.connection_type, null: false, resolve: -> (_, _, _) { Issue.where(project: [visible_project, other_project]) } + type.field :test_issues, issue_type.connection_type, null: false, + resolve: -> (_, _, _) { Issue.where(project: [visible_project, other_project]).order(id: :asc) } end end let(:query_type) do diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb index 0a27bbecfef3..dcf3c9890478 100644 --- a/spec/graphql/gitlab_schema_spec.rb +++ b/spec/graphql/gitlab_schema_spec.rb @@ -36,7 +36,7 @@ it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do connection = GraphQL::Relay::BaseConnection::CONNECTION_IMPLEMENTATIONS[ActiveRecord::Relation.name] - expect(connection).to eq(Gitlab::Graphql::Connections::KeysetConnection) + expect(connection).to eq(Gitlab::Graphql::Connections::Keyset::Connection) end describe '.execute' do diff --git a/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb new file mode 100644 index 000000000000..7fd21b6fd974 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/conditions/not_null_condition_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Conditions::NotNullCondition do + describe '#build' do + let(:condition) { described_class.new(Issue.arel_table, %w(relative_position id), [1500, 500], ['>', '>'], before_or_after) } + + context 'when there is only one ordering field' do + let(:condition) { described_class.new(Issue.arel_table, ['id'], [500], ['>'], :after) } + + it 'generates a single condition sql' do + expected_sql = <<~SQL + ("issues"."id" > 500) + SQL + + expect(condition.build).to eq expected_sql + end + end + + context 'when :after' do + let(:before_or_after) { :after } + + it 'generates :after sql' do + expected_sql = <<~SQL + ("issues"."relative_position" > 1500) + OR ( + "issues"."relative_position" = 1500 + AND + "issues"."id" > 500 + ) + OR ("issues"."relative_position" IS NULL) + SQL + + expect(condition.build).to eq expected_sql + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates :before sql' do + expected_sql = <<~SQL + ("issues"."relative_position" > 1500) + OR ( + "issues"."relative_position" = 1500 + AND + "issues"."id" > 500 + ) + SQL + + expect(condition.build).to eq expected_sql + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb new file mode 100644 index 000000000000..f52e7a5c8df4 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/conditions/null_condition_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Conditions::NullCondition do + describe '#build' do + let(:condition) { described_class.new(Issue.arel_table, %w(relative_position id), [nil, 500], [nil, '>'], before_or_after) } + + context 'when :after' do + let(:before_or_after) { :after } + + it 'generates sql' do + expected_sql = <<~SQL + ( + "issues"."relative_position" IS NULL + AND + "issues"."id" > 500 + ) + SQL + + expect(condition.build).to eq expected_sql + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates :before sql' do + expected_sql = <<~SQL + ( + "issues"."relative_position" IS NULL + AND + "issues"."id" > 500 + ) + OR ("issues"."relative_position" IS NOT NULL) + SQL + + expect(condition.build).to eq expected_sql + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb new file mode 100644 index 000000000000..fd3dd839d144 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::Connection do + let(:nodes) { Project.all.order(id: :asc) } + let(:arguments) { {} } + subject(:connection) do + described_class.new(nodes, arguments, max_page_size: 3) + end + + def encoded_cursor(node) + described_class.new(nodes, {}).cursor_from_node(node) + end + + def decoded_cursor(cursor) + JSON.parse(Base64Bp.urlsafe_decode64(cursor)) + end + + describe '#cursor_from_nodes' do + let(:project) { create(:project) } + let(:cursor) { connection.cursor_from_node(project) } + + it 'returns an encoded ID' do + expect(decoded_cursor(cursor)).to eq({ 'id' => project.id.to_s }) + end + + context 'when an order is specified' do + let(:nodes) { Project.order(:updated_at) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include({ 'updated_at' => project.updated_at.to_s }) + end + + it 'includes the :id even when not specified in the order' do + expect(decoded_cursor(cursor)).to include({ 'id' => project.id.to_s }) + end + end + + context 'when multiple orders is specified' do + let(:nodes) { Project.order(:updated_at).order(:created_at) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include({ 'updated_at' => project.updated_at.to_s }) + end + end + + context 'when multiple orders with SQL is specified' do + let(:nodes) { Project.order(Arel.sql('projects.updated_at IS NULL')).order(:updated_at).order(:id) } + + it 'returns the encoded value of the order' do + expect(decoded_cursor(cursor)).to include({ 'updated_at' => project.updated_at.to_s }) + end + end + end + + describe '#sliced_nodes' do + let(:projects) { create_list(:project, 4) } + + context 'when before is passed' do + let(:arguments) { { before: encoded_cursor(projects[1]) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + end + end + + context 'when after is passed' do + let(:arguments) { { after: encoded_cursor(projects[1]) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) + end + + context 'when the sort order is descending' do + let(:nodes) { Project.all.order(id: :desc) } + + it 'returns the correct nodes' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + end + end + + context 'when both before and after are passed' do + let(:arguments) do + { + after: encoded_cursor(projects[1]), + before: encoded_cursor(projects[3]) + } + end + + it 'returns the expected set' do + expect(subject.sliced_nodes).to contain_exactly(projects[2]) + end + end + + context 'when multiple orders are defined' do + let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3 + let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1 + let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5 + let!(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2 + let!(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4 + + context 'when ascending' do + let(:nodes) do + Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc) + end + + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'returns projects in ascending order' do + expect(subject.sliced_nodes).to eq([project5, project1, project3, project2, project4]) + end + end + + context 'when before cursor value is NULL' do + let(:arguments) { { before: encoded_cursor(project4) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project5, project1, project3, project2]) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(project3) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project5, project1]) + end + end + + context 'when after cursor value is NULL' do + let(:arguments) { { after: encoded_cursor(project2) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project4]) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(project1) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project3, project2, project4]) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project5) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project1, project3, project2]) + end + end + end + + context 'when descending' do + let(:nodes) do + Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc) + end + + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'only returns projects in descending order' do + expect(subject.sliced_nodes).to eq([project3, project1, project5, project2, project4]) + end + end + + context 'when before cursor value is NULL' do + let(:arguments) { { before: encoded_cursor(project4) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project3, project1, project5, project2]) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(project5) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq([project3, project1]) + end + end + + context 'when after cursor value is NULL' do + let(:arguments) { { after: encoded_cursor(project2) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project4]) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(project1) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project5, project2, project4]) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project3) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq([project1, project5, project2]) + end + end + end + end + + # TODO Enable this as part of below issue + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + # context 'when an invalid cursor is provided' do + # let(:arguments) { { before: 'invalidcursor' } } + # + # it 'raises an error' do + # expect { expect(subject.sliced_nodes) }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + # end + # end + + # TODO Remove this as part of below issue + # https://gitlab.com/gitlab-org/gitlab/issues/32933 + context 'when an old style cursor is provided' do + let(:arguments) { { before: Base64Bp.urlsafe_encode64(projects[1].id.to_s, padding: false) } } + + it 'only returns the project before the selected one' do + expect(subject.sliced_nodes).to contain_exactly(projects.first) + end + end + end + + describe '#paged_nodes' do + let!(:projects) { create_list(:project, 5) } + + it 'returns the collection limited to max page size' do + expect(subject.paged_nodes.size).to eq(3) + end + + it 'is a loaded memoized array' do + expect(subject.paged_nodes).to be_an(Array) + expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id) + end + + context 'when `first` is passed' do + let(:arguments) { { first: 2 } } + + it 'returns only the first elements' do + expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second) + end + end + + context 'when `last` is passed' do + let(:arguments) { { last: 2 } } + + it 'returns only the last elements' do + expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4]) + end + end + + context 'when both are passed' do + let(:arguments) { { first: 2, last: 2 } } + + it 'raises an error' do + expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + + context 'when primary key is not in original order' do + let(:nodes) { Project.order(last_repository_check_at: :desc) } + + it 'is added to end' do + sliced = subject.sliced_nodes + last_order_name = sliced.order_values.last.expr.name + + expect(last_order_name).to eq sliced.primary_key + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb new file mode 100644 index 000000000000..256113e9ae88 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/order_info_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::OrderInfo do + describe '#build_order_list' do + let(:order_list) { described_class.build_order_list(relation) } + + context 'when multiple orders with SQL is specified' do + let(:relation) { Project.order(Arel.sql('projects.updated_at IS NULL')).order(:updated_at).order(:id) } + + it 'ignores the SQL order' do + expect(order_list.count).to eq 2 + expect(order_list.first.attribute_name).to eq 'updated_at' + expect(order_list.first.operator_for(:after)).to eq '>' + expect(order_list.last.attribute_name).to eq 'id' + expect(order_list.last.operator_for(:after)).to eq '>' + end + end + end + + describe '#validate_ordering' do + let(:order_list) { described_class.build_order_list(relation) } + + context 'when number of ordering fields is 0' do + let(:relation) { Project.all } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(::ArgumentError, 'A minimum of 1 ordering field is required') + end + end + + context 'when number of ordering fields is over 2' do + let(:relation) { Project.order(last_repository_check_at: :desc).order(updated_at: :desc).order(:id) } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'A maximum of 2 ordering fields are allowed') + end + end + + context 'when the second (or first) column is nullable' do + let(:relation) { Project.order(last_repository_check_at: :desc).order(updated_at: :desc) } + + it 'raises an error' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "Column `updated_at` must not allow NULL") + end + end + + context 'for last ordering field' do + let(:relation) { Project.order(namespace_id: :desc) } + + it 'raises error if primary key is not last field' do + expect { described_class.validate_ordering(relation, order_list) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "Last ordering field must be the primary key, `#{relation.primary_key}`") + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb new file mode 100644 index 000000000000..e1dd74f931f6 --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/keyset/query_builder_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::Keyset::QueryBuilder do + context 'when number of ordering fields is 0' do + it 'raises an error' do + expect { described_class.new(Issue.arel_table, [], {}, :after) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'No ordering scopes have been supplied') + end + end + + describe '#conditions' do + let(:relation) { Issue.order(relative_position: :desc).order(:id) } + let(:order_list) { Gitlab::Graphql::Connections::Keyset::OrderInfo.build_order_list(relation) } + let(:builder) { described_class.new(arel_table, order_list, decoded_cursor, before_or_after) } + let(:before_or_after) { :after } + + context 'when only a single ordering' do + let(:relation) { Issue.order(id: :desc) } + + context 'when the value is nil' do + let(:decoded_cursor) { { 'id' => nil } } + + it 'raises an error' do + expect { builder.conditions } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'Only one sortable scope and nil was supplied') + end + end + + context 'when value is not nil' do + let(:decoded_cursor) { { 'id' => 100 } } + let(:conditions) { builder.conditions } + + context 'when :after' do + it 'generates the correct condition' do + expect(conditions.strip).to eq '("issues"."id" < 100)' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + expect(conditions.strip).to eq '("issues"."id" > 100)' + end + end + end + end + + context 'when two orderings' do + let(:decoded_cursor) { { 'relative_position' => 1500, 'id' => 100 } } + + context 'when no values are nil' do + context 'when :after' do + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" < 1500' + expect(conditions).to include '"issues"."id" > 100' + expect(conditions).to include 'OR ("issues"."relative_position" IS NULL)' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '("issues"."relative_position" > 1500)' + expect(conditions).to include '"issues"."id" < 100' + expect(conditions).to include '"issues"."relative_position" = 1500' + end + end + end + + context 'when first value is nil' do + let(:decoded_cursor) { { 'relative_position' => nil, 'id' => 100 } } + + context 'when :after' do + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" IS NULL' + expect(conditions).to include '"issues"."id" > 100' + end + end + + context 'when :before' do + let(:before_or_after) { :before } + + it 'generates the correct condition' do + conditions = builder.conditions + + expect(conditions).to include '"issues"."relative_position" IS NULL' + expect(conditions).to include '"issues"."id" < 100' + expect(conditions).to include 'OR ("issues"."relative_position" IS NOT NULL)' + end + end + end + end + end + + def arel_table + Issue.arel_table + end +end diff --git a/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb deleted file mode 100644 index 4eb121794e1b..000000000000 --- a/spec/lib/gitlab/graphql/connections/keyset_connection_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Graphql::Connections::KeysetConnection do - let(:nodes) { Project.all.order(id: :asc) } - let(:arguments) { {} } - subject(:connection) do - described_class.new(nodes, arguments, max_page_size: 3) - end - - def encoded_property(value) - Base64Bp.urlsafe_encode64(value.to_s, padding: false) - end - - describe '#cursor_from_nodes' do - let(:project) { create(:project) } - - it 'returns an encoded ID' do - expect(connection.cursor_from_node(project)) - .to eq(encoded_property(project.id)) - end - - context 'when an order was specified' do - let(:nodes) { Project.order(:updated_at) } - - it 'returns the encoded value of the order' do - expect(connection.cursor_from_node(project)) - .to eq(encoded_property(project.updated_at)) - end - end - end - - describe '#sliced_nodes' do - let(:projects) { create_list(:project, 4) } - - context 'when before is passed' do - let(:arguments) { { before: encoded_property(projects[1].id) } } - - it 'only returns the project before the selected one' do - expect(subject.sliced_nodes).to contain_exactly(projects.first) - end - - context 'when the sort order is descending' do - let(:nodes) { Project.all.order(id: :desc) } - - it 'returns the correct nodes' do - expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) - end - end - end - - context 'when after is passed' do - let(:arguments) { { after: encoded_property(projects[1].id) } } - - it 'only returns the project before the selected one' do - expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1]) - end - - context 'when the sort order is descending' do - let(:nodes) { Project.all.order(id: :desc) } - - it 'returns the correct nodes' do - expect(subject.sliced_nodes).to contain_exactly(projects.first) - end - end - end - - context 'when both before and after are passed' do - let(:arguments) do - { - after: encoded_property(projects[1].id), - before: encoded_property(projects[3].id) - } - end - - it 'returns the expected set' do - expect(subject.sliced_nodes).to contain_exactly(projects[2]) - end - end - end - - describe '#paged_nodes' do - let!(:projects) { create_list(:project, 5) } - - it 'returns the collection limited to max page size' do - expect(subject.paged_nodes.size).to eq(3) - end - - it 'is a loaded memoized array' do - expect(subject.paged_nodes).to be_an(Array) - expect(subject.paged_nodes.object_id).to eq(subject.paged_nodes.object_id) - end - - context 'when `first` is passed' do - let(:arguments) { { first: 2 } } - - it 'returns only the first elements' do - expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second) - end - end - - context 'when `last` is passed' do - let(:arguments) { { last: 2 } } - - it 'returns only the last elements' do - expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4]) - end - end - - context 'when both are passed' do - let(:arguments) { { first: 2, last: 2 } } - - it 'raises an error' do - expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) - end - end - end -end -- GitLab