summaryrefslogtreecommitdiff
path: root/lib/gitlab/graphql/pagination/keyset/order_info.rb
blob: d37264c13431b2b5cb98ba773a5815bf8c640491 (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
# 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.new('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.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

          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_similarity?(order_value)
              ['similarity', 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 SIMILARITY scoring based on Gitlab::Database::SimilarityScore
          def ordering_by_similarity?(order_value)
            Gitlab::Database::SimilarityScore.order_by_similarity?(order_value)
          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_if_ee('EE::Gitlab::Graphql::Pagination::Keyset::OrderInfo')