summaryrefslogtreecommitdiff
path: root/lib/gitlab/graphql/queries.rb
blob: 5d3a924542791d3ea5c651b31461e65908a56ace (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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# frozen_string_literal: true

require 'find'

module Gitlab
  module Graphql
    module Queries
      IMPORT_RE = /^#\s*import "(?<path>[^"]+)"$/m.freeze
      EE_ELSE_CE = /^ee_else_ce/.freeze
      HOME_RE = /^~/.freeze
      HOME_EE = %r{^ee/}.freeze
      DOTS_RE = %r{^(\.\./)+}.freeze
      DOT_RE = %r{^\./}.freeze
      IMPLICIT_ROOT = %r{^app/}.freeze
      CONN_DIRECTIVE = /@connection\(key: "\w+"\)/.freeze

      class WrappedError
        delegate :message, to: :@error

        def initialize(error)
          @error = error
        end

        def path
          []
        end
      end

      class FileNotFound
        def initialize(file)
          @file = file
        end

        def message
          "File not found: #{@file}"
        end

        def path
          []
        end
      end

      # We need to re-write queries to remove all @client fields. Ideally we
      # would do that as a source-to-source transformation of the AST, but doing it using a
      # printer is much simpler.
      class ClientFieldRedactor < GraphQL::Language::Printer
        attr_reader :fields_printed, :skipped_arguments, :printed_arguments, :used_fragments

        def initialize(skips = true)
          @skips = skips
          @fields_printed = 0
          @in_operation = false
          @skipped_arguments = [].to_set
          @printed_arguments = [].to_set
          @used_fragments = [].to_set
          @skipped_fragments = [].to_set
          @used_fragments = [].to_set
        end

        def print_variable_identifier(variable_identifier)
          @printed_arguments << variable_identifier.name
          super
        end

        def print_fragment_spread(fragment_spread, indent: "")
          @used_fragments << fragment_spread.name
          super
        end

        def print_operation_definition(op, indent: "")
          @in_operation = true
          out = +"#{indent}#{op.operation_type}"
          out << " #{op.name}" if op.name

          # Do these first, so that we detect any skipped arguments
          dirs = print_directives(op.directives)
          sels = print_selections(op.selections, indent: indent)

          # remove variable definitions only used in skipped (client) fields
          vars = op.variables.reject do |v|
            @skipped_arguments.include?(v.name) && !@printed_arguments.include?(v.name)
          end

          if vars.any?
            out << "(#{vars.map { |v| print_variable_definition(v) }.join(", ")})"
          end

          out + dirs + sels
        ensure
          @in_operation = false
        end

        def print_field(field, indent: '')
          if skips? && field.directives.any? { |d| d.name == 'client' }
            skipped = self.class.new(false)

            skipped.print_node(field)
            @skipped_fragments |= skipped.used_fragments
            @skipped_arguments |= skipped.printed_arguments

            return ''
          end

          ret = super

          @fields_printed += 1 if @in_operation && ret != ''

          ret
        end

        def print_fragment_definition(fragment_def, indent: "")
          if skips? && @skipped_fragments.include?(fragment_def.name) && !@used_fragments.include?(fragment_def.name)
            return ''
          end

          super
        end

        def skips?
          @skips
        end
      end

      class Definition
        attr_reader :file, :imports

        def initialize(path, fragments)
          @file = path
          @fragments = fragments
          @imports = []
          @errors = []
          @ee_else_ce = []
        end

        def text(mode: :ce)
          qs = [query] + all_imports(mode: mode).uniq.sort.map { |p| fragment(p).query }
          t = qs.join("\n\n").gsub(/\n\n+/, "\n\n")

          return t unless /@client/.match?(t)

          doc = ::GraphQL.parse(t)
          printer = ClientFieldRedactor.new
          redacted = doc.dup.to_query_string(printer: printer)

          return redacted if printer.fields_printed > 0
        end

        def complexity(schema)
          # See BaseResolver::resolver_complexity
          # we want to see the max possible complexity.
          fake_args = Struct
            .new(:if, :keyword_arguments)
            .new(nil, { sort: true, search: true })

          query = GraphQL::Query.new(schema, text)
          # We have no arguments, so fake them.
          query.define_singleton_method(:arguments_for) { |_x, _y| fake_args }

          GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryComplexity]).first
        end

        def query
          return @query if defined?(@query)

          # CONN_DIRECTIVEs are purely client-side constructs
          @query = File.read(file).gsub(CONN_DIRECTIVE, '').gsub(IMPORT_RE) do
            path = $~[:path]

            if EE_ELSE_CE.match?(path)
              @ee_else_ce << path.gsub(EE_ELSE_CE, '')
            else
              @imports << fragment_path(path)
            end

            ''
          end
        rescue Errno::ENOENT
          @errors << FileNotFound.new(file)
          @query = nil
        end

        def all_imports(mode: :ce)
          return [] if query.nil?

          home = mode == :ee ? @fragments.home_ee : @fragments.home
          eithers = @ee_else_ce.map { |p| home + p }

          (imports + eithers).flat_map { |p| [p] + @fragments.get(p).all_imports(mode: mode) }
        end

        def all_errors
          return @errors.to_set if query.nil?

          paths = imports + @ee_else_ce.flat_map { |p| [@fragments.home + p, @fragments.home_ee + p] }

          paths.map { |p| fragment(p).all_errors }.reduce(@errors.to_set) { |a, b| a | b }
        end

        def validate(schema)
          return [:client_query, []] if query.present? && text.nil?

          errs = all_errors.presence || schema.validate(text)
          if @ee_else_ce.present?
            errs += schema.validate(text(mode: :ee))
          end

          [:validated, errs]
        rescue ::GraphQL::ParseError => e
          [:validated, [WrappedError.new(e)]]
        end

        private

        def fragment(path)
          @fragments.get(path)
        end

        def fragment_path(import_path)
          frag_path = import_path.gsub(HOME_RE, @fragments.home)
          frag_path = frag_path.gsub(HOME_EE, @fragments.home_ee + '/')
          frag_path = frag_path.gsub(DOT_RE) do
            Pathname.new(file).parent.to_s + '/'
          end
          frag_path = frag_path.gsub(DOTS_RE) do |dots|
            rel_dir(dots.split('/').count)
          end
          frag_path.gsub(IMPLICIT_ROOT) do
            (Rails.root / 'app').to_s + '/'
          end
        end

        def rel_dir(n_steps_up)
          path = Pathname.new(file).parent
          while n_steps_up > 0
            path = path.parent
            n_steps_up -= 1
          end

          path.to_s + '/'
        end
      end

      class Fragments
        def initialize(root, dir = 'app/assets/javascripts')
          @root = root
          @store = {}
          @dir = dir
        end

        def home
          @home ||= (@root / @dir).to_s
        end

        def home_ee
          @home_ee ||= (@root / 'ee' / @dir).to_s
        end

        def get(frag_path)
          @store[frag_path] ||= Definition.new(frag_path, self)
        end
      end

      def self.find(root)
        definitions = []

        ::Find.find(root.to_s) do |path|
          definitions << Definition.new(path, fragments) if query_for_gitlab_schema?(path)
        end

        definitions
      rescue Errno::ENOENT
        [] # root does not exist
      end

      def self.fragments
        @fragments ||= Fragments.new(Rails.root)
      end

      def self.all
        ['.', 'ee'].flat_map do |prefix|
          find(Rails.root / prefix / 'app/assets/javascripts')
        end
      end

      def self.known_failure?(path)
        @known_failures ||= YAML.safe_load(File.read(Rails.root.join('config', 'known_invalid_graphql_queries.yml')))

        @known_failures.fetch('filenames', []).any? { |known_failure| path.to_s.ends_with?(known_failure) }
      end

      def self.query_for_gitlab_schema?(path)
        path.ends_with?('.graphql') &&
          !path.ends_with?('.fragment.graphql') &&
          !path.ends_with?('typedefs.graphql') &&
          !/.*\.customer\.(query|mutation)\.graphql$/.match?(path)
      end
    end
  end
end