summaryrefslogtreecommitdiff
path: root/lib/gitlab/graphql/pagination/keyset/connection.rb
blob: b074c27399697da0e8e1e7a3cbd349f68038acac (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# 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. If it's not already included, an order on the
# primary key will be added automatically, like `order(id: :desc)`
#
#   Issue.order(created_at: :asc).order(:id)
#   Issue.order(due_date: :asc)
#
# 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 Pagination
      module Keyset
        class Connection < GraphQL::Pagination::ActiveRecordRelationConnection
          include Gitlab::Utils::StrongMemoize
          include ::Gitlab::Graphql::ConnectionCollectionMethods
          prepend ::Gitlab::Graphql::ConnectionRedaction

          # rubocop: disable Naming/PredicateName
          # https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo.Fields
          def has_previous_page
            strong_memoize(:has_previous_page) do
              if after
                # If `after` is specified, that points to a specific record,
                # even if it's the first one.  Since we're asking for `after`,
                # then the specific record we're pointing to is in the
                # previous page
                true
              elsif last
                limited_nodes
                !!@has_previous_page
              else
                # Key thing to remember.  When `before` is specified (and no `last`),
                # the spec says return _all_ edges minus anything after the `before`.
                # Which means the returned list starts at the very first record.
                # Then the max_page kicks in, and returns the first max_page items.
                # Because of this, `has_previous_page` will be false
                false
              end
            end
          end

          def has_next_page
            strong_memoize(:has_next_page) do
              if before
                true
              elsif first
                case sliced_nodes
                when Array
                  sliced_nodes.size > limit_value
                else
                  sliced_nodes.limit(1).offset(limit_value).exists? # rubocop: disable CodeReuse/ActiveRecord
                end
              else
                false
              end
            end
          end
          # rubocop: enable Naming/PredicateName

          def cursor_for(node)
            order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(items)
            encode(order.cursor_attributes_for_node(node).to_json)
          end

          def sliced_nodes
            sliced = ordered_items
            sliced = slice_nodes(sliced, before, :before) if before.present?
            sliced = slice_nodes(sliced, after, :after) if after.present?
            sliced
          end

          def 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.
            @nodes ||= limited_nodes.to_a
          end

          def items
            original_items = super
            return original_items if Gitlab::Pagination::Keyset::Order.keyset_aware?(original_items)

            strong_memoize(:keyset_pagination_items) do
              rebuilt_items_with_keyset_order, success =
                Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(original_items)

              raise(Gitlab::Pagination::Keyset::UnsupportedScopeOrder) unless success

              rebuilt_items_with_keyset_order
            end
          end

          private

          # Apply `first` and `last` to `sliced_nodes`
          def limited_nodes
            strong_memoize(:limited_nodes) do
              if first && last
                raise Gitlab::Graphql::Errors::ArgumentError, "Can only provide either `first` or `last`, not both"
              end

              if last
                paginated_nodes = LastItems.take_items(sliced_nodes, limit_value + 1)

                # there is an extra node, so there is a previous page
                @has_previous_page = paginated_nodes.count > limit_value
                @has_previous_page ? paginated_nodes.last(limit_value) : paginated_nodes
              elsif loaded?(sliced_nodes)
                sliced_nodes.take(limit_value) # rubocop: disable CodeReuse/ActiveRecord
              else
                sliced_nodes.limit(limit_value) # rubocop: disable CodeReuse/ActiveRecord
              end
            end
          end

          # rubocop: disable CodeReuse/ActiveRecord
          def slice_nodes(sliced, encoded_cursor, before_or_after)
            order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(sliced)
            order = order.reversed_order if before_or_after == :before

            decoded_cursor = ordering_from_encoded_json(encoded_cursor)
            order.apply_cursor_conditions(sliced, decoded_cursor)
          end
          # rubocop: enable CodeReuse/ActiveRecord

          def limit_value
            # note: only first _or_ last can be specified, not both
            @limit_value ||= [first, last, max_page_size].compact.min
          end

          def loaded?(items)
            case items
            when Array
              true
            else
              items.loaded?
            end
          end

          def ordered_items
            strong_memoize(:ordered_items) do
              unless items.primary_key.present?
                raise ArgumentError, 'Relation must have a primary key'
              end

              items
            end
          end

          def ordering_from_encoded_json(cursor)
            Gitlab::Json.parse(decode(cursor))
          rescue JSON::ParserError
            raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor"
          end
        end
      end
    end
  end
end