Commit eafd98ec authored by Sean McGivern's avatar Sean McGivern

Merge branch 'bw-add-json-cursor-to-keyset-connection' into 'master'

GraphQL: Allow a keyset_connection to support multiple ordering attributes

See merge request gitlab-org/gitlab!17788
parents ddfc8244 3d29bff4
...@@ -146,6 +146,10 @@ query($project_path: ID!) { ...@@ -146,6 +146,10 @@ query($project_path: ID!) {
} }
``` ```
To ensure that we get consistent ordering, we will append an ordering on the primary
key, in descending order. This is usually `id`, so basically we will add `order(id: :desc)`
to the end of the relation. A primary key _must_ be available on the underlying table.
### Exposing permissions for a type ### Exposing permissions for a type
To expose permissions the current user has on a resource, you can call To expose permissions the current user has on a resource, you can call
......
...@@ -46,7 +46,7 @@ module DesignManagement ...@@ -46,7 +46,7 @@ module DesignManagement
join = designs.join(actions) join = designs.join(actions)
.on(actions[:design_id].eq(designs[:id])) .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 end
# Scope called by our REST API to avoid N+1 problems # Scope called by our REST API to avoid N+1 problems
......
...@@ -6,7 +6,7 @@ module Gitlab ...@@ -6,7 +6,7 @@ module Gitlab
def self.use(_schema) def self.use(_schema)
GraphQL::Relay::BaseConnection.register_connection_implementation( GraphQL::Relay::BaseConnection.register_connection_implementation(
ActiveRecord::Relation, ActiveRecord::Relation,
Gitlab::Graphql::Connections::KeysetConnection Gitlab::Graphql::Connections::Keyset::Connection
) )
end end
end end
......
# 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 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
# frozen_string_literal: true
module Gitlab
module Graphql
module Connections
module Keyset
module Conditions
class NotNullCondition < BaseCondition
def build
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, final_condition]
end
conditions.join
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
# frozen_string_literal: true
module Gitlab
module Graphql
module Connections
module Keyset
module Conditions
class NullCondition < BaseCondition
def build
[first_attribute_condition, final_condition].join
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
# 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-foss/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
# TODO https://gitlab.com/gitlab-org/gitlab/issues/35104
include Gitlab::Graphql::Connections::Keyset::LegacyKeysetConnection
def cursor_from_node(node)
return legacy_cursor_from_node(node) if use_legacy_pagination?
encoded_json_from_ordering(node)
end
def sliced_nodes
return legacy_sliced_nodes if use_legacy_pagination?
@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
unless nodes.primary_key.present?
raise ArgumentError.new('Relation must have a primary key')
end
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
# frozen_string_literal: true
# TODO https://gitlab.com/gitlab-org/gitlab/issues/35104
module Gitlab
module Graphql
module Connections
module Keyset
module LegacyKeysetConnection
def legacy_cursor_from_node(node)
encode(node[legacy_order_field].to_s)
end
# rubocop: disable CodeReuse/ActiveRecord
def legacy_sliced_nodes
@sliced_nodes ||=
begin
sliced = nodes
sliced = sliced.where(legacy_before_slice) if before.present?
sliced = sliced.where(legacy_after_slice) if after.present?
sliced
end
end
# rubocop: enable CodeReuse/ActiveRecord
private
def use_legacy_pagination?
strong_memoize(:feature_disabled) do
Feature.disabled?(:graphql_keyset_pagination, default_enabled: true)
end
end
def legacy_before_slice
if legacy_sort_direction == :asc
arel_table[legacy_order_field].lt(decode(before))
else
arel_table[legacy_order_field].gt(decode(before))
end
end
def legacy_after_slice
if legacy_sort_direction == :asc
arel_table[legacy_order_field].gt(decode(after))
else
arel_table[legacy_order_field].lt(decode(after))
end
end
def legacy_order_info
@legacy_order_info ||= nodes.order_values.first
end
def legacy_order_field
@legacy_order_field ||= legacy_order_info&.expr&.name || nodes.primary_key
end
def legacy_sort_direction
@legacy_order_direction ||= legacy_order_info&.direction || :desc
end
end
end
end
end
end
# 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 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 ArgumentError.new("Column `#{attribute_name}` must not allow NULL")
end
if order_list.last.attribute_name != relation.primary_key
raise 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
# 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 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('Before/after cursor invalid: `nil` was provided as only sortable value')
end
if attr_names.count == 1 || attr_values.first.present?
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 operators
order_list.map { |field| field.operator_for(before_or_after) }
end
end
end
end
end
end
# 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
...@@ -259,7 +259,8 @@ describe 'Gitlab::Graphql::Authorization' do ...@@ -259,7 +259,8 @@ describe 'Gitlab::Graphql::Authorization' do
let(:project_type) do |type| let(:project_type) do |type|
type_factory do |type| type_factory do |type|
type.graphql_name 'FakeProjectType' 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
end end
let(:query_type) do let(:query_type) do
......
...@@ -36,7 +36,7 @@ describe GitlabSchema do ...@@ -36,7 +36,7 @@ describe GitlabSchema do
it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do
connection = GraphQL::Relay::BaseConnection::CONNECTION_IMPLEMENTATIONS[ActiveRecord::Relation.name] 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 end
describe '.execute' do describe '.execute' do
......
# 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.squish).to eq expected_sql.squish
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.squish).to eq expected_sql.squish
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.squish).to eq expected_sql.squish
end
end
end
end
# 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.squish).to eq expected_sql.squish
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.squish).to eq expected_sql.squish
end
end
end
end
This diff is collapsed.
# frozen_string_literal: true
# TODO https://gitlab.com/gitlab-org/gitlab/issues/35104
require 'spec_helper'
describe Gitlab::Graphql::Connections::Keyset::LegacyKeysetConnection do
describe 'old keyset_connection' do
let(:described_class) { Gitlab::Graphql::Connections::Keyset::Connection }
let(:nodes) { Project.all.order(id: :asc) }
let(:arguments) { {} }
subject(:connection) do
described_class.new(nodes, arguments, max_page_size: 3)
end
before do
stub_feature_flags(graphql_keyset_pagination: false)
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
end
# 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(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(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(ArgumentError, "Last ordering field must be the primary key, `#{relation.primary_key}`")
end
end
end
end
# 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(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, 'Before/after cursor invalid: `nil` was provided as only sortable value')
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
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment