summaryrefslogtreecommitdiff
path: root/lib/gitlab/graphql/pagination/keyset/order_info.rb
blob: 57e85ebe7f662d7187b4e80b74ae61daaf858ad4 (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
# frozen_string_literal: true

module Gitlab
  module Graphql
    module Pagination
      module Keyset
        class OrderInfo
          attr_reader :attribute_name, :sort_direction, :named_function

          def initialize(order_value)
            @attribute_name, @sort_direction, @named_function =
              if order_value.is_a?(String)
                extract_nulls_last_order(order_value)
              else
                extract_attribute_values(order_value)
              end
          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
          def self.build_order_list(relation)
            order_list = relation.order_values.select do |value|
              supported_order_value?(value)
            end

            order_list.map { |info| OrderInfo.new(info) }
          end

          def self.validate_ordering(relation, order_list)
            if order_list.empty?
              raise ArgumentError, 'A minimum of 1 ordering field is required'
            end

            if order_list.count > 2
              # Keep in mind an order clause for primary key is added if one is not present
              # lib/gitlab/graphql/pagination/keyset/connection.rb:97
              raise ArgumentError, '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, "Column `#{attribute_name}` must not allow NULL"
            end

            if order_list.last.attribute_name != relation.primary_key
              raise ArgumentError, "Last ordering field must be the primary key, `#{relation.primary_key}`"
            end
          end

          def self.supported_order_value?(order_value)
            return true if order_value.is_a?(Arel::Nodes::Ascending) || order_value.is_a?(Arel::Nodes::Descending)
            return false unless order_value.is_a?(String)

            tokens = order_value.downcase.split

            tokens.last(2) == %w(nulls last) && tokens.count == 4
          end

          private

          def extract_nulls_last_order(order_value)
            tokens = order_value.downcase.split

            column_reference = tokens.first
            sort_direction = tokens[1] == 'asc' ? :asc : :desc

            # Handles the case when the order value is coming from another table.
            # Example: table_name.column_name
            # Query the value using the fully qualified column name: pass table_name.column_name as the named_function
            if fully_qualified_column_reference?(column_reference)
              [column_reference, sort_direction, Arel.sql(column_reference)]
            else
              [column_reference, sort_direction, nil]
            end
          end

          # Example: table_name.column_name
          def fully_qualified_column_reference?(attribute)
            attribute.to_s.count('.') == 1
          end

          def extract_attribute_values(order_value)
            if ordering_by_lower?(order_value)
              [order_value.expr.expressions[0].name.to_s, order_value.direction, order_value.expr]
            elsif ordering_by_case?(order_value)
              ['case_order_value', order_value.direction, order_value.expr]
            elsif ordering_by_array_position?(order_value)
              ['array_position', order_value.direction, order_value.expr]
            else
              [order_value.expr.name, order_value.direction, nil]
            end
          end

          # determine if ordering using LOWER, eg. "ORDER BY LOWER(boards.name)"
          def ordering_by_lower?(order_value)
            order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'lower'
          end

          # determine if ordering using ARRAY_POSITION, eg. "ORDER BY ARRAY_POSITION(Array[4,3,1,2]::smallint, state)"
          def ordering_by_array_position?(order_value)
            order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'array_position'
          end

          # determine if ordering using CASE
          def ordering_by_case?(order_value)
            order_value.expr.is_a?(Arel::Nodes::Case)
          end
        end
      end
    end
  end
end

Gitlab::Graphql::Pagination::Keyset::OrderInfo.prepend_mod_with('Gitlab::Graphql::Pagination::Keyset::OrderInfo')